Compare commits
426 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa8c9c6f12 | |||
| 2237a35db5 | |||
| bccca45486 | |||
| 648c676024 | |||
| 8f2d9732d6 | |||
| 9ee9c85b09 | |||
| 5cea89cc66 | |||
| 27cceb0341 | |||
| 9ae9c39e55 | |||
| 30a742618e | |||
| 9a688207ba | |||
| ea1d943432 | |||
| 3db6f59e69 | |||
| 19dc0c7d56 | |||
| 920ce2646b | |||
| 94d7cff485 | |||
| 8a66ea5a0a | |||
| cf7ee77a9e | |||
| 5ae8b1fdd1 | |||
| 269e507580 | |||
| 408ad87a0e | |||
| 855c9f40b1 | |||
| a66493f9b7 | |||
| b023719555 | |||
| 09d8a12add | |||
| 91996a941b | |||
| 262041795c | |||
| 80a67fb493 | |||
| e8190c65f7 | |||
| ab373aff30 | |||
| 21d7f58f61 | |||
| 212bfc24ac | |||
| 8dfbb02c93 | |||
| cb40662e4e | |||
| 6e4494195c | |||
| a50ab27a19 | |||
| d20aac4aeb | |||
| 9fd8238051 | |||
| f2046d7f0a | |||
| 2a58cfb235 | |||
| 1aa24507fc | |||
| 8d39904d2c | |||
| 4a8f855cfc | |||
| 547ea61708 | |||
| beb001846f | |||
| af00932aca | |||
| 87931afc41 | |||
| ad2ac1cfd7 | |||
| db78696865 | |||
| b00d533f03 | |||
| c69a779ca4 | |||
| a37266f865 | |||
| 3bf005619e | |||
| 4d87094a98 | |||
| 3862ae6f4e | |||
| b6537bd54b | |||
| 9b4114012b | |||
| 1473396e5b | |||
| dc8d71c6a7 | |||
| 2b994a7a89 | |||
| c9ab15c392 | |||
| 9fb46b786f | |||
| 02a78fcca6 | |||
| e0dfc1032e | |||
| cc335def00 | |||
| e8c991648d | |||
| 17863001f3 | |||
| 9ad22f2e26 | |||
| 9ef1a59f2c | |||
| 6bea57599c | |||
| eee2888a2b | |||
| 00675ca09c | |||
| 0fa1dee4b5 | |||
| 9a4dc80f11 | |||
| 320854eebc | |||
| 7d997272ef | |||
| a76e0154b6 | |||
| 28a940c9f5 | |||
| e018762589 | |||
| f0a836363c | |||
| e12dfc7871 | |||
| b2cb42caf5 | |||
| 40c1e0f148 | |||
| bcd9fb9a4c | |||
| 22a0a58f27 | |||
| 5976ef89b4 | |||
| e6ced47323 | |||
| 82c5733906 | |||
| ff3feb6079 | |||
| c1cd86e0c2 | |||
| bf6730f028 | |||
| 85cc4b9535 | |||
| 0e06b918a7 | |||
| 0d63e5592f | |||
| 78bc91d297 | |||
| 14c43f37b3 | |||
| d5aed563a1 | |||
| 4a353720d0 | |||
| 7250dbd63a | |||
| 92506e72b9 | |||
| d1ea4fe450 | |||
| c444468d2a | |||
| 9109b68924 | |||
| 152878062f | |||
| 1aa1b44063 | |||
| fb4e4bd7b2 | |||
| 0b9e9e4c5e | |||
| 8430dd1694 | |||
| abc9ea2751 | |||
| 3c5882d16d | |||
| 475c13aeb8 | |||
| c18b6cd0d4 | |||
| 7c2841ca06 | |||
| cc480c30b7 | |||
| 2c49c54d2d | |||
| 5b8324ed02 | |||
| 4f0b2a743c | |||
| 3660a7e173 | |||
| 2bb1528ca9 | |||
| 84887d8518 | |||
| f71b8ab73f | |||
| c8fbaad548 | |||
| 35eab8924a | |||
| 5a3c4c3529 | |||
| 65c565e058 | |||
| 5dd056beb8 | |||
| c14450c3ae | |||
| 967d9e64c7 | |||
| 355bcadb59 | |||
| 6360dc5bae | |||
| f059d05040 | |||
| e147ab90b4 | |||
| 0f69c6d588 | |||
| 00181ba3ad | |||
| 6e4eb5932c | |||
| 391d4a322d | |||
| ad2a253f2a | |||
| 69abbe2fa5 | |||
| 7061f42781 | |||
| a26a2b6648 | |||
| 994ee6d2a3 | |||
| 0cccd62230 | |||
| f261695b86 | |||
| 39723406b4 | |||
| 12da7ed593 | |||
| cafec549fa | |||
| 0114c4748b | |||
| 9f7876b5fa | |||
| 31715ba58a | |||
| 18e705a8c3 | |||
| 6eeb8dacb3 | |||
| 386958b261 | |||
| cc2edb3d8a | |||
| 4ab133c266 | |||
| 795a1fea49 | |||
| 212bdf6fa9 | |||
| 666fc2cf6c | |||
| 032f70f509 | |||
| 162930eb96 | |||
| 81a7aace15 | |||
| 61bb1ab621 | |||
| b7798a7fd9 | |||
| aa4bf8e008 | |||
| 4906c91463 | |||
| ee747003f2 | |||
| 599d84fd66 | |||
| be411a7b4b | |||
| 80c26f14f2 | |||
| 0dc79a3ba4 | |||
| ef949e6a17 | |||
| 55adeb0fbd | |||
| cf9c9b7bab | |||
| 3d4e4e81f8 | |||
| f21d47538f | |||
| 755470e4ba | |||
| 56dbc3f915 | |||
| 904eca1758 | |||
| 61a52a3f05 | |||
| f942056d21 | |||
| 0027c405c1 | |||
| ddf5cbb8e7 | |||
| f007d5c9be | |||
| bd5dc69c4e | |||
| 0b7b6b8157 | |||
| a935361ca6 | |||
| e820a7ed41 | |||
| 30918e4f4a | |||
| c3117e90fa | |||
| 3b5a1495f5 | |||
| 90fe409dec | |||
| 85ba2a709d | |||
| f65a512440 | |||
| 7c81f02308 | |||
| cf931da2f9 | |||
| 991be7353b | |||
| 3c104f5685 | |||
| 5372a770eb | |||
| 2d268f024b | |||
| 31162efc8c | |||
| face2268ca | |||
| 6403fb9f29 | |||
| 9a3174a324 | |||
| bab787fac1 | |||
| 7c683fcb0a | |||
| 138760f75c | |||
| daaddf21cb | |||
| 05c14b42dd | |||
| a0704a8fd0 | |||
| 9fb2ad5d2b | |||
| 29864617eb | |||
| 50fa8744fd | |||
| c39250f1b4 | |||
| 40f20d539e | |||
| 9e85f522f6 | |||
| 39c82f26f3 | |||
| 36d2f71bc7 | |||
| b92a47cd44 | |||
| 18a36a8f27 | |||
| 73ef3a67ca | |||
| b43e3118cf | |||
| d37a29b389 | |||
| 0255a956e2 | |||
| 18e0db75a9 | |||
| 2d2a40a73c | |||
| d1eb88d4d7 | |||
| d1c6a9c27a | |||
| 722e237ea4 | |||
| 3a4a098f1a | |||
| 4c3fa64c95 | |||
| 25f001a778 | |||
| d9e6c6c9dd | |||
| b88f04fc69 | |||
| 8064625d56 | |||
| 31ece16ada | |||
| 719956baee | |||
| bed66c8ff8 | |||
| 337e5df770 | |||
| 0f8c6dea50 | |||
| 73ff6a60bd | |||
| 7803d8520d | |||
| 82f45d488f | |||
| 052d90c03e | |||
| 9cbf301f09 | |||
| 731cb53105 | |||
| c011c02f8e | |||
| f17e7cb8da | |||
| cab54a5504 | |||
| a5f7ca840d | |||
| 1035bf069d | |||
| b8734189cf | |||
| 7aee1818b8 | |||
| 43f50d3b72 | |||
| f72054f61f | |||
| fa600cb45d | |||
| b87b3905ee | |||
| 7fd3a3c973 | |||
| a387cdc1b6 | |||
| 910c5abfad | |||
| bed1852110 | |||
| cb63ff286d | |||
| f6d62c225c | |||
| f87f3e9841 | |||
| a7ec67b19c | |||
| d3f5aaa443 | |||
| 86808ce55d | |||
| 91c642a646 | |||
| d087632573 | |||
| 563b211e15 | |||
| 69b4caf5d8 | |||
| a4d5415034 | |||
| e3502c059e | |||
| f8ba5dea2c | |||
| 0446d2a93c | |||
| dfb7b694f9 | |||
| 87052212b7 | |||
| ef3726441b | |||
| 4730e81ec2 | |||
| bfb455b3c8 | |||
| 93ed870f75 | |||
| f966276217 | |||
| 3cab9867be | |||
| e9f0a50ea5 | |||
| cad14e2b5a | |||
| 2c793720f6 | |||
| 3c6bc7ac02 | |||
| a688c63919 | |||
| 6131d34d28 | |||
| ef9faabdf8 | |||
| cd802d965e | |||
| 6bea10bb5b | |||
| bb13ed45f8 | |||
| b1539d7cd5 | |||
| 81b50199cc | |||
| 97bc8f929d | |||
| cee59bd611 | |||
| 15121cff20 | |||
| 8aa9e8eaef | |||
| ccabef883e | |||
| 163a859658 | |||
| 8848df1c1e | |||
| bc5d46eccb | |||
| 99183acc89 | |||
| 0d92ea8f24 | |||
| 158f1c676f | |||
| eb83238ae7 | |||
| 4f6e48f0e0 | |||
| 3dceb5c56b | |||
| 4a99db13d0 | |||
| ac18c3e2cf | |||
| 9c64da86a3 | |||
| 0fe51491d8 | |||
| 2a03c74941 | |||
| ce7d91cfe0 | |||
| 235388a5d0 | |||
| 6d5d23487a | |||
| ab91fcff54 | |||
| a59e374ff9 | |||
| f4462c14e7 | |||
| 404fdf5a1c | |||
| b02f28f6ce | |||
| eaa2453853 | |||
| 59630623e9 | |||
| dd7eecb279 | |||
| b0b8d02fe7 | |||
| 0698c9e90c | |||
| c734b46731 | |||
| 8e68673509 | |||
| aa33d25466 | |||
| 20fbfd99a5 | |||
| 8c90958718 | |||
| ef8b651f28 | |||
| f6c1dd0751 | |||
| e957757fda | |||
| c341571300 | |||
| 3403abd6b9 | |||
| 5e3fac260b | |||
| b811906751 | |||
| e32a6e17e4 | |||
| ff7a1ad5d3 | |||
| 92f646df08 | |||
| f411f12c56 | |||
| 4dd09740b7 | |||
| 3463a68292 | |||
| e255677dd9 | |||
| a352d8fe3f | |||
| 175ca934d8 | |||
| f07ccc5229 | |||
| ea1730b206 | |||
| c9ae494f62 | |||
| 0370fa58ad | |||
| 2213e25385 | |||
| bf8abbb0fd | |||
| a9a4d03b09 | |||
| fb6f80ef1d | |||
| 2494e04984 | |||
| b49e6728b4 | |||
| dfb9551eb7 | |||
| 8459040856 | |||
| a5cccee17c | |||
| 2a559371ae | |||
| e0e86e31af | |||
| 2ec7a2d6c0 | |||
| 26f0063696 | |||
| ddc6cb8f9d | |||
| 85a66af7a5 | |||
| 92271d6726 | |||
| 4598a70db2 | |||
| 82fb2c8800 | |||
| f16e24ba11 | |||
| 8b814eb2f8 | |||
| 56c0e60462 | |||
| 68aa37f093 | |||
| 5485d61390 | |||
| 45c72d874f | |||
| 1fe5c6347d | |||
| c4ed064318 | |||
| 2a43424ae6 | |||
| 5b9e821d6d | |||
| f1ed6a2a48 | |||
| 4b19b58bb0 | |||
| 6f63b5172c | |||
| 6d30e7095e | |||
| a261675aad | |||
| 450f1de68a | |||
| 74efc20bf5 | |||
| d2bd2ed798 | |||
| ccd0b6018c | |||
| 9363f209ee | |||
| 33443d4f28 | |||
| fe10603aea | |||
| 57eb412b6d | |||
| 7288c22830 | |||
| 25787017e5 | |||
| b50be83da4 | |||
| 6c57ff4fd5 | |||
| 895d0b7b36 | |||
| ef00b2c3f4 | |||
| 0fa9548c59 | |||
| 30ff0f7a74 | |||
| 5d096453fb | |||
| d728a5a341 | |||
| f64f2dac7c | |||
| 612f267349 | |||
| 00c84307aa | |||
| efbdb5024c | |||
| fa4064f1e8 | |||
| 71da10b637 | |||
| aa6a9ed1b5 | |||
| 36281e02fd | |||
| 88f67f5e13 | |||
| 994075920d | |||
| 130b9d7e3b | |||
| 1e067dc883 | |||
| 6bfbb00994 | |||
| 944fccf67b | |||
| 758603a9ad | |||
| 47539c594f | |||
| 8d725642dd | |||
| 2d7b2f4ef0 | |||
| 2f299c769d | |||
| 74741a37cd | |||
| 26ee8974cc | |||
| 025e90a270 | |||
| 0e459d1224 | |||
| 2e889e0dce | |||
| a9275477fb |
@@ -0,0 +1 @@
|
||||
*.html linguist-detectable=false
|
||||
@@ -0,0 +1,95 @@
|
||||
name: release
|
||||
|
||||
# Builds the release package from the tagged source and publishes it.
|
||||
# The package is a snapshot of the tree without the development-only files
|
||||
# (tools/build_release.py); pysim is not bundled - setup installs it from
|
||||
# upstream. Trigger: push a v* tag (or run manually for an existing tag).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to package and publish (e.g. v3.31.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the tagged source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: simple
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
# The Python tests expect a pysim checkout as a sibling of the repo
|
||||
# (tests/*.py prepend <repo>/../pysim to sys.path).
|
||||
- name: Check out pysim next to the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: osmocom/pysim
|
||||
path: pysim
|
||||
|
||||
# 3.13+ is required by the suite: ssl's TLS-PSK callbacks
|
||||
# (SCP81's PskTlsServer) exist only since Python 3.13.
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
# pyscard has no manylinux wheels, so it builds from source on Linux
|
||||
# and needs the PC/SC development headers.
|
||||
- name: Install the PC/SC development headers
|
||||
run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
|
||||
|
||||
- name: Install pysim and the test dependencies
|
||||
working-directory: simple
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r requirements-pysim.txt
|
||||
python -m pip install --no-deps ../pysim
|
||||
|
||||
- name: Python suite
|
||||
working-directory: simple
|
||||
run: python -m unittest discover -s tests
|
||||
|
||||
# sp.test.js / the AES tests require the npm runtime dependencies
|
||||
# (des.js, aes-js); the build deps come along with them.
|
||||
- name: Install the frontend test dependencies
|
||||
working-directory: simple/frontend
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Frontend suite
|
||||
working-directory: simple/frontend
|
||||
run: node --test
|
||||
|
||||
- name: Build the release package
|
||||
working-directory: simple
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
python tools/build_release.py --expect-version "${TAG#v}" --out dist
|
||||
|
||||
- name: Publish the release
|
||||
working-directory: simple
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
VER="${TAG#v}"
|
||||
ZIP="dist/simple-$VER.zip"
|
||||
# The release body is the CHANGELOG section of the version; a missing
|
||||
# section falls back to generated notes.
|
||||
if python tools/changelog_section.py "$VER" > /tmp/notes.md 2>/dev/null; then
|
||||
NOTES="--notes-file /tmp/notes.md"
|
||||
else
|
||||
NOTES="--generate-notes"
|
||||
fi
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" "$ZIP" --clobber
|
||||
else
|
||||
gh release create "$TAG" "$ZIP" --title "$TAG" $NOTES
|
||||
fi
|
||||
@@ -4,3 +4,6 @@ __pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
AGENTS.md
|
||||
simple_lastrun*.log
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to SIMple (the PWA and `pysim-simple-server`).
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/);
|
||||
versions use `MAJOR.MINOR.PATCH` (see the README).
|
||||
|
||||
History before the first packaged release (v3.31.1) lives in the git log —
|
||||
every change carries a descriptive commit message.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.32.0] — 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Updating a packaged install** — `update.sh` / `update.bat` (and `tools/update.py`
|
||||
beneath them): downloads the latest release package, replaces the application files
|
||||
in place and re-installs the app into the **existing `.venv`**. pysim and its
|
||||
prerequisites (the slow part: pyscard builds from source on Linux) are kept, so an
|
||||
update takes seconds. `--check` reports the current and latest versions, `--version`
|
||||
picks a specific release, `--zip` updates from a local package (offline), `--no-pip`
|
||||
refreshes the files only, `--force` re-applies the same version.
|
||||
- The release workflow now takes the release notes from this file's matching version
|
||||
section (falling back to generated notes when it is missing).
|
||||
|
||||
### Changed
|
||||
|
||||
- Installs made from the v3.31.x packages have no updater yet: copy `update.sh`,
|
||||
`update.bat` and `tools/update.py` from this package once (or re-run `setup` from it).
|
||||
Later updates use `update.sh` / `update.bat` — or `git pull` for a clone.
|
||||
|
||||
## [3.31.2] — 2026-10-20
|
||||
|
||||
### Changed
|
||||
|
||||
- The **SCP81 TLS-PSK listener states its Python requirement**: it needs Python 3.13+
|
||||
(`ssl`'s PSK callbacks exist only since 3.13). Below that it raises a clear error
|
||||
instead of `AttributeError`, and the PSK tests are skipped (they run on 3.13+).
|
||||
- The READMEs and the setup scripts: **Windows strongly recommends Python 3.13**
|
||||
(pyscard has wheels up to 3.13; on 3.14 pyscard and some pysim dependency wheels are
|
||||
unavailable and build from source with MSVC).
|
||||
|
||||
## [3.31.1] — 2026-10-20
|
||||
|
||||
The first packaged release (`simple-3.31.1.zip`).
|
||||
|
||||
### Added
|
||||
|
||||
- **EF_PBR (phonebook reference) support** — the PBR is decoded in the decoded file
|
||||
views (the `A8`/`A9`/`AA` containers, the `C0`–`CC` kinds, the master EF), and the
|
||||
phonebook files it references (they are not in pySim's model) are added to the file
|
||||
tree server-side, selectable and readable.
|
||||
- **In-app dialogs** — the native alert/confirm/prompt dialogs are replaced by a shared
|
||||
modal (queued, keyboard-aware, focus-trapped, ARIA-labelled).
|
||||
- **Windows console encoding guard** — the `start.bat` PowerShell pipe can no longer
|
||||
kill the server on a character the ANSI code page lacks.
|
||||
- **GPL-2.0-or-later licensing** (`LICENSE`, `THIRD-PARTY-NOTICES`), matching pysim.
|
||||
- The curated **release package** (`tools/build_release.py`) and the tag-triggered
|
||||
`.github/workflows/release.yml`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `setup.sh` / `setup.bat` and the READMEs: on Linux `pyscard` builds from source and
|
||||
needs the PC/SC headers (`libpcsclite-dev`).
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -1,23 +1,51 @@
|
||||
# OTAMan — SIM OTA toolkit: PWA + local card server
|
||||
# SIMple — a UICC workbench: APDU · OTA · STK · profiling
|
||||
|
||||
OTAMan is an offline HTML/JS PWA for building APDU commands (SIM, USIM, GlobalPlatform RAM), assembling SCP80 secured packets per ETSI TS 102 225, and constructing Expanded Remote Application data format APDU per ETSI TS 102 226. A bundled [`pysim-otaman-server`](pysim_otaman_server/) exposes a local HTTP API over pySim for live card operations: file manager, raw APDU, SIM Toolkit menu browsing, and OTA (SCP80) delivery.
|
||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
||||
|
||||
**Demo:** [otaman.atroshin.ru](https://otaman.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
|
||||
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
|
||||
|
||||
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA lab** — SCP80 secured packets (TS 102 225) and Java Card RAM installation, plus the terminal side of HTTP OTA (SCP81): BIP channel emulation, a PSK TLS administration server and APDU script execution;
|
||||
- **Phone/CAT simulator** — SIM Toolkit menu browser, proactive command / event handling, TERMINAL PROFILE editing and the C-AT dialogue log;
|
||||
- **Card profiler** — filesystem capture (profiles and immutable snapshots with timings), exact snapshot comparison and FCP/FCI decoding.
|
||||
|
||||
**Demo:** [simple.atroshin.ru](https://simple.atroshin.ru) — the PWA alone, for experimenting. Install the server (below) for card-reader functions.
|
||||
|
||||
## Quick start
|
||||
|
||||
**PWA only (client-side tools):** open `frontend/index.html` in any browser, or serve `frontend/` with any static server. No Python required.
|
||||
|
||||
**Full (PWA + card server):**
|
||||
**Full (PWA + card server)** — two ways to get it:
|
||||
|
||||
**1. Download the release package (recommended).** From the
|
||||
[Releases](https://github.com/anttro/simple/releases) page take the latest package
|
||||
(`simple-<version>.zip`), unzip it anywhere and run the setup script once:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
./setup.sh # or setup.bat on Windows — creates .venv, installs pysim + server
|
||||
./start.sh # or start.bat — starts the server (it serves the PWA too)
|
||||
```
|
||||
|
||||
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed.
|
||||
The package is built from the tagged source — no git history, no development files.
|
||||
Git is still required: `setup` installs **pysim** from its upstream repository
|
||||
(nothing from pysim or its dependencies is bundled).
|
||||
|
||||
**Updates are fast:** `./update.sh` (or `update.bat`) downloads the latest release
|
||||
package, replaces the application files in place and re-installs the app into the
|
||||
existing `.venv` — pysim and its prerequisites are kept, so nothing is rebuilt.
|
||||
`./update.sh --check` reports the current and latest versions, `--version X.Y.Z`
|
||||
picks a specific release and `--zip file.zip` updates from a local package.
|
||||
|
||||
**2. Clone the repository (for development, or changes not released yet).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git # or --depth 1: no history,
|
||||
cd simple # `git pull` still updates it
|
||||
./setup.sh # or setup.bat on Windows
|
||||
./start.sh # or start.bat
|
||||
```
|
||||
|
||||
Then open http://127.0.0.1:8080 — the UI and API share one origin, so no CORS or browser-permission setup is needed. The server starts fine with an empty reader (no card is a normal state): insert a card and it is initialized automatically (auto-equip), or press **Equip card** in the UI.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -31,13 +59,13 @@ npm run build
|
||||
|
||||
## Interface
|
||||
|
||||
Four top-level tabs: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. The C-APDU and SCP80 tabs each have sub-tabs.
|
||||
Seven top-level tabs: **Remote APDU**, **SCP80**, **SCP81**, **Cards**, **Profiler**, **Card reader**, and **Simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the **SCP81** tab has **Listener** and **Scripts** pills; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## C-APDU tab
|
||||
## Remote APDU tab
|
||||
|
||||
Builds command APDUs (C-APDUs). Five sub-tabs cover different card generations and command sets.
|
||||
Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations, command sets and decoding tools: **SIM RFM**, **USIM RFM**, **RAM/GP**, **Expanded Script**, **Push commands**, and **Parser** (sub-pills **C-APDU Parser** / **R-APDU parser**).
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -57,6 +85,9 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| DEACTIVATE FILE | 04 | Deactivate a file |
|
||||
| VERIFY PIN | 20 | Verify PIN1 or PIN2 |
|
||||
| CHANGE PIN | 24 | Change PIN1 or PIN2 |
|
||||
| UNBLOCK PIN | 2C | Unblock a PIN with the PUK |
|
||||
| SEARCH RECORD (SEEK) | A2 | Search a linear fixed EF for a pattern; P2 selects type 1/2 and the mode (TS 151 011 §9.2.7) |
|
||||
| INCREASE | 32 | Add a value to the last increased/updated record of a cyclic EF (3-byte value, TS 151 011 §9.2.8) |
|
||||
|
||||
#### SELECT methods
|
||||
|
||||
@@ -67,6 +98,15 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| By DF name / AID | 04 | 00 | AID (application ID) |
|
||||
| ADF RFM chain | 00 | 00 | Comma-separated FIDs, each selected in turn |
|
||||
|
||||
#### SELECT file picker
|
||||
|
||||
Every SELECT row offers a file picker: a filter box, an **all files** toggle, the session start root with the current DF, and the file list grouped by root (`MF`, `ADF.USIM`, ...). Picking a file fills the fields for the current method; when the method cannot express the selection the picker switches to one that can - preferring **path** (one SELECT), then **chain** - and says so, e.g. `7F106F40` (path from MF) or `7F20,6F46` (relative FID chain).
|
||||
|
||||
- **List source**: with a card equipped the loaded file-manager tree wins (card names, probed presence; probed-absent files are hidden). Without a card, or for branches the tree has not loaded, the shipped standard list is used: `uicc_files.json`, generated from pySim's profiles/application classes by `pysim_simple_server/uicc_files.py` and drift-guarded by `tests/test_uicc_files.py`. It is specs-default until **Probe all files** runs in the file manager. Custom files are always merged.
|
||||
- **Session start**: the implicit current DF follows the TAR the secured packet is sent to (UICC Shared File System RFM -> MF, TS 102 226 §7.2; ADF RFM -> its ADF, §7.3). Defaults: MF for both builders; changeable per row (pick the ADF to work in an ADF session).
|
||||
- **Path** is the FID sequence from MF without the MF identifier (ISO 7816-4); the USIM "from current DF" variant uses the relative tail. **Chain** stays relative to the current DF and follows the TS 102 221 §11.1.1.2 FID search order (children, parent, siblings).
|
||||
- ADF roots are not pickable - an application is selected by AID, which the **By AID** method keeps manual (TS 102 226 §7.1 forbids P1=`04` for RFM).
|
||||
|
||||
#### Options
|
||||
|
||||
- **Start with SELECT** — checkbox to prepend a SELECT command before the operation. When unchecked, the operation is sent standalone with CLA.
|
||||
@@ -84,6 +124,18 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
|
||||
CLA = `00` (ETSI TS 102 221). Same commands as SIM, but SELECT uses P1=09, P2=0C (by FID from current directory).
|
||||
|
||||
The UICC administrative command set is available on top of the SIM set (ETSI TS 102 221/102 222):
|
||||
|
||||
| Command | INS | Description |
|
||||
|---|---|---|
|
||||
| SEARCH RECORD | A2 | Simple (P2=10/12, P1 = record number) and enhanced (P2=18, 2-byte indication) search per TS 102 221 §11.1.7 |
|
||||
| INCREASE | 32 | Add a value to a cyclic EF record; optional SFI in P1 (TS 102 221 §11.1.8) |
|
||||
| CREATE FILE | E0 | Create an EF from an FCP template; **Build template** composes a structural skeleton (TS 102 222 §6.3) |
|
||||
| DELETE FILE | E4 | Delete the EF/DF with the given FID (TS 102 222 §6.4) |
|
||||
| RESIZE FILE | D4 | Resize a file (FCP with FID + new size); CLA `80` per TS 102 222 Table 1 (TS 102 222 §6.10) |
|
||||
| SET DATA | DB | Write a BER-TLV object; P2 selects first/next/retransmit block (+SFI) (TS 102 221 §11.3.2) |
|
||||
| RETRIEVE DATA | CB | Read a BER-TLV object or the tag list (`5C`); P2 as above (TS 102 221 §11.3.1) |
|
||||
|
||||
#### References
|
||||
|
||||
- ETSI TS 102 221: UICC-Terminal Interface; Physical and Logical Characteristics
|
||||
@@ -103,11 +155,17 @@ Two encoding variants:
|
||||
|
||||
| Type | Tag | Description |
|
||||
|---|---|---|
|
||||
| C-APDU | 22 | Raw APDU hex |
|
||||
| C-APDU | 22 | Any remote management command: raw hex or built with the SIM RFM / USIM RFM / RAM-GP editors (TS 102 226 §5.2.1.0) |
|
||||
| Immediate Action | 81 | Proactive command or action indicator |
|
||||
| Error Action | 82 | Proactive command on error |
|
||||
| Script Chaining | 83 | Chaining data for multi-packet scripts |
|
||||
|
||||
#### C-APDU rows
|
||||
|
||||
A C-APDU row starts in **Hex** mode (paste any command) and can switch to a one-row **SIM RFM**, **USIM RFM** or **RAM/GP** editor that builds the command from fields — the resulting APDU is wrapped in the `22` TLV and echoed next to the picker. GET RESPONSE is not offered there: the expanded format carries no GET RESPONSE (TS 102 226 §5.2.1.1). **→ Expanded Script** in the SIM RFM / USIM RFM / RAM-GP views appends their built commands as C-APDU rows (GET RESPONSE hops dropped).
|
||||
|
||||
Only REFRESH, DISPLAY TEXT and PLAY TONE are allowed as an Immediate Action (TS 102 226 Table 5.5) and only DISPLAY TEXT and PLAY TONE as an Error Action (Table 5.9); the behaviour of the card for any other proactive command is undefined.
|
||||
|
||||
#### Immediate Action builder
|
||||
|
||||
When the type is set to Immediate Action, the tool provides a structured builder for:
|
||||
@@ -143,9 +201,13 @@ CLA = `80` (GlobalPlatform Card Specification v2.3.1). Remote Application Manage
|
||||
| GET DATA | CA | tag | Read card data objects |
|
||||
| STORE DATA | E2 | 00/40/80/C0 | Store data (key, certificate, etc.) |
|
||||
| SET STATUS | F0 | 80/40/60 | Lifecycle state management |
|
||||
| PUT KEY | D0 | key ver | Replace a key: P1 = key version, P2 = key identifier, data = new key material (GP Card Spec §11.8) |
|
||||
| PUSH | EC | 01 | Ask an application to open a BIP channel / CAT_TP link / TCP connection or send an identification packet (TS 102 226 §9) |
|
||||
| EXTERNAL AUTHENTICATE | 82 | 00 | SCP host authentication |
|
||||
| INTERNAL AUTHENTICATE | 88 | 00 | Card challenge-response |
|
||||
|
||||
**PUSH** (TS 102 226 §9.2) is a C-APDU (`80 EC 01 P2`) addressed to an application that supports BIP and/or CAT_TP — not the HTTP administration trigger (that is the HTTP OTA builder above). P2: `01` BIP channel opening (OPEN CHANNEL COMPREHENSION-TLVs, optional), `02` CAT_TP link (destination port in transport level `3C` with protocol type 00, optional buffer size `39` / channel data `36`), `03` TCP connection (bearer `35`, transport level `3C` with protocol type 02 = TCP client remote, destination address `3E`, NAA/APN `47`), `04` identification packet (channel data `36`; the ICCID is used when absent). The application issues the proactive OPEN CHANNEL itself; success is `90 00`, failure `6F 00` with the Result TLV in the response data, malformed data is rejected with `6A 80`.
|
||||
|
||||
#### INSTALL [for install] — Privilege Builder
|
||||
|
||||
Tag `C7` in the INSTALL data field. Built from 3 privilege bytes (GP spec Tables 11-7, 11-8, 11-9):
|
||||
@@ -312,11 +374,58 @@ Swaps nibble pairs of an even-length hex string.
|
||||
- ETSI TS 102 225: Secured packet structure for (U)SIM toolkit
|
||||
- pySim: enc_imsi() implementation
|
||||
|
||||
### C-APDU Parser
|
||||
|
||||
Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an **Expanded Script** (leading `AA` or `AE80`, decoded per ETSI TS 102 226 §5.2.1) or a **Compact C-APDU chain** (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex, and a short description; parent nodes expand to reveal their sub-elements.
|
||||
|
||||
### Push commands
|
||||
|
||||
Groups the commands that make the card dial out. Three sub-pills switch between the forms - HTTP OTA Trigger (Push SMS), HTTP OTA Store (SD admin params) and Channel / link trigger - and only the selected one is shown (the two GP forms share their fields; the mode switches the heading and its mode-specific rows - the SD params tag in Store, the Command Scripting template in Trigger - and the payload wrapping). They are delivered differently - the administration trigger is a TLV message for the Security Domain, a §9 PUSH is a C-APDU (`80 EC 01 P2`) for an application that supports BIP/CAT_TP.
|
||||
|
||||
#### Administration session (HTTP OTA)
|
||||
|
||||
Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Two modes:
|
||||
|
||||
- **Trigger (Push SMS)** — administration session triggering parameters (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.
|
||||
- **Store (SD admin params)** — writes the same parameters as card (Security Domain) data via **STORE DATA in TLV mode** (`80 E2 90 00`, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag `85` (or `A5`) per Table 4-4.
|
||||
|
||||
| Section | Tag | Contents |
|
||||
|---|---|---|
|
||||
| Connection parameters | `84` | COMPREHENSION-TLVs needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities `82`, Alpha `05`, Command details `81`, Bearer `35` (`03` = default bearer), vendor TLVs. Row editor + presets, editable hex. |
|
||||
| Security parameters | `85` | Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279). |
|
||||
| Retry policy | `86` | Table 4-7: retry counter (2 bytes, e.g. `B000`), retry waiting delay as the TS 102 223 timer TLV (`25 03 HH MM SS`), optional vendor-specific report-failure TLV. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: Host header (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — text converted to octets. |
|
||||
|
||||
The **Command Scripting template** checkbox wraps the whole `81` triggering command in the definite-length Expanded Remote Application data format (`AA`, ETSI TS 102 226 §5.2.1) for TARs that process the expanded format. **Pack into Secured packet** sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.
|
||||
|
||||
#### TS 102 226 §9 PUSH
|
||||
|
||||
The **Channel / link trigger** sub-pill builds the §9 requests with the same encoder as the RAM/GP chain's **PUSH** row and offers **Pack into Secured packet** (TAR stays manual - the request goes to the target application) and **→ Expanded Script** (each APDU becomes a `22` Command TLV):
|
||||
|
||||
- **BIP channel opening** (`01`) - built in *Fields* mode from the most-used OPEN CHANNEL parameters (bearer, UDP/TCP client remote + port, destination address, NAA/APN, buffer size, alpha; the local address is never emitted), or in *Hex* mode with the raw COMPREHENSION-TLVs; the application issues the proactive OPEN CHANNEL itself. SIMalliance Stepping Stones R7 §18.6 recommends sending it together with the CAT_TP request in the same message (channel parameters in `01`, CAT_TP port in `02`) - the form has an *Also request the CAT_TP link establishment (02)* checkbox and the pair goes out as one concatenated command script;
|
||||
- **CAT_TP link** (`02`) - destination port in transport level `3C` with protocol type 00 (mandatory), optional buffer size `39` / identification data `36`; standalone it relies on card-provisioned defaults, so prefer the pair above;
|
||||
- **TCP connection** (`03`) - bearer `35`, transport level `3C` with protocol type 02, destination address `3E` (`21` IPv4 / `57` IPv6 / `F0` FQDN), NAA/APN `47`; the parameters are the OPEN CHANNEL TCP set and are mandatory in the request, so `03` is self-contained (no companion `01`) - the connection is established over BIP or a direct IP connection (TS 102 483) where supported;
|
||||
- **Identification packet** (`04`) is not a trigger - it needs an already open TCP channel, so the TCP request has an *Also send the identification packet (04)* checkbox that appends it in the same message; `04` also stays in the chain's PUSH row for scripts.
|
||||
- **PoR** - Stepping Stones R7 §18.6.3 recommends *not* requesting a PoR for a successful push (it may disturb the ME-card communication); request one only to investigate a failure, in SMS-SUBMIT mode (SCP80 SPI2 bit `0x20`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
### R-APDU parser
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## SCP80 tab
|
||||
|
||||
The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **Cards**, and **RAM**. Assembles secured packets per ETSI TS 102 225.
|
||||
The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **RAM** and **TAR probe**. Assembles secured packets per ETSI TS 102 225. When a card is equipped the server reads its EF.ICCID (2FE2); if a preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.
|
||||
|
||||
### Secured Packet
|
||||
|
||||
@@ -327,7 +436,7 @@ Builds SCP80 secured packets per ETSI TS 102 225.
|
||||
| Field | Size | Description |
|
||||
|---|---|---|
|
||||
| CPI | 1 | Command Packet Identifier (`02`) |
|
||||
| CPL | 1 | Command Packet Length |
|
||||
| CPL | 2 (SMS) | Command Packet Length (CHL..end incl. padding) |
|
||||
| CHI | 1 | Command Header Identifier (`01`) |
|
||||
| CHL | 1 | Command Header Length |
|
||||
| SPI | 2 | Security Parameter Indicator |
|
||||
@@ -336,7 +445,7 @@ Builds SCP80 secured packets per ETSI TS 102 225.
|
||||
| TAR | 3 | Toolkit Application Reference |
|
||||
| CNTR | 5 | Replay counter |
|
||||
| PCNTR | 1 | Padding counter |
|
||||
| RC/CC/DS | 8 | Cryptographic Checksum / MAC |
|
||||
| RC/CC/DS | 4–8 | Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature |
|
||||
| Secured Data | variable | Padded APDU (encrypted if required) |
|
||||
|
||||
#### SPI1 (Security Level)
|
||||
@@ -391,6 +500,10 @@ PoR confirms the card received and executed the secured packet. Two modes:
|
||||
|
||||
Delivery PoR (SPI2 `01`) is simpler — the card returns the PoR directly in the ENVELOPE response. Submit PoR (SPI2 `21`) is used when the card cannot respond inline (e.g. during ELF operations where the ENVELOPE response space is limited).
|
||||
|
||||
#### SMS delivery
|
||||
|
||||
A secured packet is delivered in an SMS-PP-DOWNLOAD ENVELOPE. Its size in bytes and the number of SMS it takes are shown under the packet field; a packet that does not fit one SMS is delivered as a **concatenated** SMS-PP download (TS 31.115 §4.3): the packet is split into SMS user-data parts (first SM 132 octets, following ones 134 — the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. A packet that would need more than 5 segments is refused — that is the practical limit of the card's concatenation buffer.
|
||||
|
||||
#### References
|
||||
|
||||
- ETSI TS 102 225 V18.1.0: Secured packet structure for UICC based applications
|
||||
@@ -398,38 +511,21 @@ Delivery PoR (SPI2 `01`) is simpler — the card returns the PoR directly in the
|
||||
- ISO 9797-1: MAC algorithms
|
||||
- NIST SP 800-38B: CMAC
|
||||
|
||||
### Cards
|
||||
|
||||
Stores saved card configurations (presets). Each preset stores the cryptographic keys, SPI settings, TAR, and replay counter needed for SCP80 operations.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| SPI1 / SPI2 | Security level and PoR settings |
|
||||
| KIc / KID key | Encryption and MAC key hex |
|
||||
| KIc / KID index | Key version number |
|
||||
| TAR | Toolkit Application Reference (3 bytes) |
|
||||
| Counter (CNTR) | 10-digit hex replay counter, auto-incremented after each successful SCP80 send |
|
||||
|
||||
**Add a card:** fill in the name, SPI1/SPI2, KIc/KID keys and indices, TAR, and click **Add**. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
|
||||
|
||||
**Edit a card:** click a card in the list, modify fields, click **Save**.
|
||||
|
||||
**Delete a card:** select a card, click **Delete**. Removes the preset from `localStorage`.
|
||||
|
||||
**Counter:** the 10-digit hex counter (CNTR) is auto-incremented after each successful SCP80 send (both manual Secured Packet sends and RAM operations). The updated counter is saved back to the preset automatically.
|
||||
|
||||
### RAM
|
||||
|
||||
All RAM operations are delivered as SCP80 secured packets (ETSI TS 102 225) via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES) for secure transport.
|
||||
|
||||
Select a saved card configuration from the **Card preset** dropdown. If no preset is selected, the RAM tab warns and refuses to execute.
|
||||
|
||||
The RAM subtab offers two operations selected from the **Operation** dropdown:
|
||||
The RAM subtab offers five operations selected from the **Operation** dropdown:
|
||||
|
||||
| Operation | Description |
|
||||
|---|---|
|
||||
| **Explore Card (all GP data)** | Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item **Delete** buttons. |
|
||||
| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
|
||||
| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. The load file is split into LOAD APDUs of 1–240 bytes of payload (240 by default, editable in **LOAD block size**); a secured packet larger than one SMS is delivered as a concatenated SMS-PP download of up to 5 segments. |
|
||||
| **CAP compatibility test** | Answers whether the card can satisfy the CAP's import list without installing anything: the server runs the format check, INSTALL\[for load\] and the LOAD blocks only up to the block completing the **Import** component (where the JCRE verifies the import list), then stops — nothing is committed and nothing has to be deleted. The **Import versions to test** list is pre-filled from the CAP analysis (`AID=version` rewrites a requested version, `+AID=version` appends a synthetic capability query, **Set all 0.0** drops every requirement); press Set all 0.0 first, then restore one line at a time to find the missing package and version. Covers the LOAD/import gate only. |
|
||||
| **INSTALL \[for install\] (loaded package)** | One INSTALL \[for install\] APDU for a package already loaded on the card (Load File AID + Module AID, privileges, install/STK parameters, optional make selectable) — iterate the parameters without re-loading the `.cap`. |
|
||||
| **INSTALL \[for make selectable\]** | One INSTALL \[for make selectable\] APDU (the instance AID) for an INSTALLED, non-selectable applet instance. |
|
||||
|
||||
#### Explorer View
|
||||
|
||||
@@ -443,45 +539,119 @@ Delete confirms via a browser prompt before sending the GP `DELETE` command via
|
||||
|
||||
---
|
||||
|
||||
## Response parser tab
|
||||
### TAR probe
|
||||
|
||||
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
|
||||
The third SCP80 pill probes **which OTA applications the card has registered**: it sends one secured packet per
|
||||
selected TAR — the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 (pre-filled and all checked) plus
|
||||
custom TARs added with **Add TAR** and kept in `localStorage` — each carrying a harmless C-APDU (`SELECT MF` by
|
||||
default), with the view's preset and keyset and SPI `16/01` (counter check + PoR). Every answer is classified:
|
||||
**registered** (ENVELOPE 9000 + a PoR carrying the application's own status word and data), **accepted, no answer**
|
||||
(a PoR without an R-APDU), **accepted, no PoR** (the packet was silently dropped) and **refused** (the ENVELOPE
|
||||
itself was rejected). Each packet the card accepts consumes a counter, which is saved into the preset's keyset; the
|
||||
probe is refused when SPI1 has no counter check or the named preset does not define the keyset number.
|
||||
`POST /api/tar-probe` (see `docs/api.md`).
|
||||
|
||||
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
|
||||
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
|
||||
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
|
||||
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
|
||||
---
|
||||
|
||||
## Cards
|
||||
|
||||
Stores saved card configurations (presets) in a **server-side store** — `~/.pysim-simple-server/card_presets.json` (`--card-presets` overrides it). A preset holds the cryptographic keys, SPI settings, TARs and the replay counters for SCP80 operations, the optional **ADM** key, plus the **PSK identity / PSK key** pair used by the SCP81 HTTP OTA listener. A preset carries **several keysets** — a keyset's number is the `b8..b5` nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (`01`–`0F`; A.2 rejects a mismatch), `00` (no security) is not a preset keyset, and each keyset has its own counter on the card (Annex A.1) — the server owns those counters, persists every consumed increment and appends it to an audit trail (`card_presets.json.audit.jsonl`). The list is loaded when the PWA connects; JSON export/import (which also accepts the old flat presets) is the migration path from the browser-local store. Cards is a **top-level tab**. The form groups the fields into two bordered blocks — **SCP80 (GSM 03.48, ETSI TS 102 225)** and **SCP81 (HTTP OTA)** — with the optional **ADM** field in the top row; the keysets are edited as rows (one per number, **Add keyset** / **Remove keyset**). When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches **no** preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The header shows gray **SCP80** / **SCP81** markers and a key glyph on the **ADM** badge when the preset matching the equipped card's ICCID has those settings filled in.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Name | Human-readable label (required) |
|
||||
| ICCID | Optional card identifier |
|
||||
| ADM | Optional administrator PIN (hex, or up to 8 ASCII digits), stored for the file manager; not used by the SCP80/SCP81 views |
|
||||
| PIN1 / PUK1 / PIN2 / PUK2 | Optional card codes, stored for later use (4–8 decimal digits; nothing consumes them yet) |
|
||||
| CAT release | Optional card CAT (USAT) release (4–18; empty = unspecified, resolved as Rel-12) — the release guard hides CAT items introduced later (the Simulator's event parameters) |
|
||||
| SPI1 / SPI2 | Security level and PoR settings |
|
||||
| KIc / KID index | Keyset number (`01`–`0F`, from the KIc/KID byte; required together with the keys) |
|
||||
| KIc / KID key | Encryption and MAC key hex |
|
||||
| ISD TAR | Issuer Security Domain TAR (TS 101 220 Annex D), used for RAM/GP operations; spec default `000000` |
|
||||
| UICC RFM TAR | UICC Shared File System RFM TAR (TS 102 226 §7.2), used by the SIM RFM view; spec default `B00000` |
|
||||
| ADF RFM TAR | ADF RFM TAR (TS 102 226 §7.3), linked to the ADF AID (ADF.USIM in the USIM RFM view); spec default `B00001` |
|
||||
| Counter (CNTR) | 10-digit hex replay counter, one per keyset; the server advances and saves it after each operation that consumes it (nothing is tracked when SPI1 b5 b4 = `00`, TS 102 225 §5.1.1) |
|
||||
| PSK identity | SCP81 HTTP OTA: the identity the card sends in the TLS handshake |
|
||||
| PSK key | SCP81 HTTP OTA: 32 hex chars (16 bytes); the listener picks it by the identity the card presents |
|
||||
|
||||
The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓** (identity and key), **⚠** (only one of the two — the listener ignores such a preset), **—** (no PSK). Identity and key must be set together.
|
||||
|
||||
**Add a card:** fill in the name, ICCID (optional — **From card** fills it from the equipped card's EF.ICCID), the optional **ADM**, SPI1/SPI2, one or more keysets (KIc/KID + keys per number, **Add keyset**), the three TARs, the SCP81 PSK pair (optional) and click **Add**. A duplicate ICCID (compared ignoring spaces and the raw-hex form) is refused, naming the conflicting preset. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown.
|
||||
|
||||
**Edit / remove:** **Edit** loads a preset into the form (the Add button becomes **Save**; **Cancel** clears the form); **Remove** deletes the preset from the server store. A successful SCP80 send advances and stores the counter of the keyset it used, and edits are pushed into a running SCP81 listener automatically.
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (pySim integration)
|
||||
|
||||
Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations.
|
||||
Connects to the bundled [`pysim-simple-server`](pysim_simple_server/) for live card operations.
|
||||
|
||||
> **Browser restriction:** when the PWA is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-otaman-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
|
||||
> **Browser restriction:** when the PWA is served from a public HTTPS host, reaching the local server (`http://127.0.0.1:8080`) requires two things: the server must send `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 does this automatically), and the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to `127.0.0.1` is blocked before any preflight is sent.
|
||||
|
||||
### File Browser
|
||||
|
||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
|
||||
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Selecting a file shows its detail pane: FID, file type, size / record layout and the decoded FCI (in a bordered block labelled with the file's symbolic name) above the content pane. When the FCI references its access rules through an EF.ARR record (security attribute `8B`), the referenced record's decoded rules are shown as indented lines under that row — resolved from the card's cached EF.ARR copies (MF, DF.TELECOM/DF.GSM and ADF.USIM, following the file's DF chain; a snapshot resolves them from its own captured copy).
|
||||
|
||||
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
||||
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
||||
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
||||
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills pinned above the tree together with **Probe all files**, remembered in `localStorage`)
|
||||
- **Read raw** / **Read decoded** — read the selected file as a hex dump or as a decoded field table (client-side decoders for IMSI, ICCID, SPN, PLMN lists, LOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the highlighted pill is the current view and clicking either pill (re-)reads the file. The server-side pySim JSON of the same read stays behind a collapsed *pySim JSON (server)* disclosure; **Read decoded** is disabled when the client-side decoders do not cover the file (resolved by name/FID before reading)
|
||||
- **Edit raw** — edit the raw hex data and **Save** to write back (or **Cancel**); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — **Edit raw** switches to the hex view first and reads the file if needed
|
||||
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy. Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||
- **ADM** — files that need the administrator PIN fail with `6982`/`9804`; when the matching card preset (same ICCID) carries an ADM key, a **Verify ADM** button appears next to the error and the header badge (`ADM ✓/✗ ⚿`) becomes clickable. Every wrong key consumes an attempt (the remaining attempts are shown and a retry asks for confirmation); a blocked ADM needs the card's unblock key
|
||||
|
||||
### Custom Files
|
||||
### Command Hints
|
||||
|
||||
Files not in pysim's model can be added manually:
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
|
||||
1. Switch to the **Custom files** sub-tab
|
||||
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
|
||||
3. Click **Add** — the file appears in the tree in italics (unverified)
|
||||
4. Click the file to verify existence — on success, it behaves like a model file
|
||||
---
|
||||
|
||||
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
|
||||
## Profiler
|
||||
|
||||
### Proactive UICC Pill
|
||||
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
|
||||
|
||||
The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session interaction:
|
||||
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
||||
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Clone** (copies the profile as *Copy of <name>* and opens the copy in the editor), **Export** (download JSON), and **Delete**.
|
||||
|
||||
A filesystem rule is defined by:
|
||||
|
||||
- **Path** — `MF`-rooted (e.g. `MF/7F10/6F3A`) or ADF AID-rooted (e.g. `A0000000871002/6F07`).
|
||||
- **FCP/FCI check** — **Filetype only (FCP)**, **Filetype + size (FCP)** (adds file size, or record length/count for record files), or **Exact FCI** (byte-for-byte comparison of the raw SELECT FCP template `'62'`, catching FID/AID, life-cycle status, security-attribute, and proprietary-parameter changes).
|
||||
- **Access rules** (optional) — verifies the file's access conditions as decoded rules: the FCI's `8B` reference is resolved against the card's (or snapshot's) EF.ARR copies and the decoded lines are compared as a set — record-number independent, so cards with a different EF.ARR layout still verify (missing/extra lines are listed; a lost reference fails; an unresolvable one is unverifiable). Captured automatically when a profile is created from a card or a snapshot; the editor shows each rule as structured rows (operation bits / RESIZE FILE / INCREASE plus security-condition selects, with a canonical preview line; `+ rule` adds one, raw template forms stay read-only) and the block is collapsible with None hiding it. With **Exact FCI**, *Ignore the EF.ARR reference in the exact FCI* compares the FCI without the `8B` value — a different record number no longer fails while the rules check owns its meaning.
|
||||
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
|
||||
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
|
||||
|
||||
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓, access rules ✗*), lists mismatches as read-only monospace expected/actual fields aligned in one column, shows content mismatches of files with a decoder as a decoded per-field comparison (only the differing fields, expected and actual side by side; the raw pair is kept when the decoded values match), and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. Access-rule mismatches list the decoded rule lines (✓ matching, − missing, + extra). In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
||||
|
||||
#### “Profile from card” scan options
|
||||
|
||||
The scan dialog asks for a profile name and offers the FCP/FCI mode described above, an **Ignore contents of files** checklist of frequently-overwritten files (all checked by default except `EF.ARR`; the header checkbox toggles the whole list) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — and two checked-by-default mask options that capture only the first 4 bytes of `EF.IMSI` and `EF.ICCID` (uncheck for exact matching). A progress line shows *N / total files* with the current path; the options are locked while scanning. Rules are created only for files that actually exist (a FCP template is returned); custom files from the **Custom files** sub-tab are included under the same existence check.
|
||||
|
||||
#### Card snapshots
|
||||
|
||||
The list view has three tabs — **Profiles**, **Card snapshots** and **Custom files**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. Captured contents are shown with their decoded form where a decoder exists — a field table for transparent files and a one-line summary per record for record files. The scan also measures every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons.
|
||||
|
||||
- **New snapshot** scans the card; **Import snapshot** loads JSON.
|
||||
- Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**.
|
||||
- **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors.
|
||||
- **Compare snapshots** compares two snapshots offline like a profile check but byte-exact for the FCI and the EF.ARR contents (the semantic access-rules aspect belongs to profiles): pick the *master* snapshot and the *snapshot to check* and get the same report — snapshot comparison is always exact (no content masking). Files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Custom files
|
||||
|
||||
Files not in pysim's model can be added manually from the **Custom files** tab in the Profiler list:
|
||||
|
||||
1. Pick the **Root (MF / ADF)** and type the **Parent DF** path — the root itself, a standard DF known from the file-manager tree, or a custom DF (any depth; suggestions while you type). A parent that has not been seen in the tree yet stays valid and is marked `⚠`
|
||||
2. Enter the 4-hex **FID** and an alias (`EF.…`/`DF.…`; the prefix decides whether the entry is an EF or a DF)
|
||||
3. Click **Add** — the file appears in the File manager tree; use **Edit** on a row to reload it into the form (the button becomes **Save**, **Cancel** aborts) or **Delete** to remove it (deleting a DF also deletes its child entries after a confirmation)
|
||||
|
||||
The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Custom files persist in `localStorage` across sessions and are included in card scans under the same existence check. Export/import as JSON for sharing; legacy relative paths (e.g. `a153/4954`) are resolved on load, unresolvable ones are dropped and reported in the list.
|
||||
|
||||
## Simulator
|
||||
|
||||
The **Simulator** tab provides real-time CAT session interaction. It has five pills: **Phone** (STK menu, STATUS and polling, subscribed events, proactive command log), **TR Config** (response data injected into TERMINAL RESPONSEs for proactive commands), **eSIM LPA** (local eUICC operations), **BIP** (the generic terminal side of BIP over TCP) and **Test script** (form-based scripts of card actions and proactive-command expectations, stored in a server-side store).
|
||||
|
||||
**Subscribed Events** — the card's SET UP EVENT LIST is displayed with per-event **Send** buttons. Clicking opens a form specific to the event type:
|
||||
|
||||
@@ -489,14 +659,18 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
- **Location Status** — dropdown for Normal / Limited / No service
|
||||
- **Access Technology Change** — dropdown for all 13 RAT types
|
||||
- **Card Reader Status, Language, UICC Access** — appropriate inputs
|
||||
- **Channel Status** — channel selector, link state (not established / TCP
|
||||
LISTEN / established) and info (no further info / link dropped), per TS 102 223 8.56
|
||||
- **Network Rejection** — full adaptive form with registration type dropdown
|
||||
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
|
||||
technology selection, and 53-cause unified rejection cause code dropdown
|
||||
covering EMM, GMM, 5GMM, and LU causes
|
||||
|
||||
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
|
||||
**Proactive Command Log** — chronological list of proactive commands encountered (elapsed time, type code, name and a decoded qualifier; expanding a row shows the decoded command and the TERMINAL RESPONSE). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT, REFRESH and the BIP commands (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP commands are decoded with both plain and comprehension-required TLV tags.
|
||||
|
||||
**PLI Data Dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
|
||||
**Timer management** — the server acts as the terminal for TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): started timers are tracked per card session, deactivate/get TERMINAL RESPONSEs carry the remaining value, and on expiry the card receives ENVELOPE (TIMER EXPIRATION). The live card uses this to retry the OTA session after a failed OPEN CHANNEL.
|
||||
|
||||
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
|
||||
|
||||
| Code | Decoded fields |
|
||||
|------|--------------|
|
||||
@@ -513,48 +687,91 @@ The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session
|
||||
|
||||
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
|
||||
|
||||
### Command Hints
|
||||
**Network simulation** — replays the card-facing write patterns of a real phone on network-condition changes (trace study: `projects/UICC_NAA.md`): **Cold boot**, **EPS attach**, **2G attach**, **Service lost**, **Limited service**, **Roaming denied**, **Churn**, **SMS received**, **CB reconfig** and **AUTHENTICATE**. Each scenario sends the Location status event (only when the card subscribed to it), updates the EPS NAS context, location files, Kc and CB/SMS files exactly as observed, and logs every step with its SW. Parameters (collapsed) cover the operator (searchable worldwide MCC/MNC list served by the server, plus a random roaming picker and a **Home network** button that fills the card's HPLMN from EF.HPLMNwAcT's first record, falling back to the IMSI), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), scenario toggles and the churn count/delay. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; EF.FPLMN is appended only by **Roaming denied** (TS 31.102 §4.2.16, duplicates skipped) and an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), while the 5GS location files are never written. The operator list is bundled (`pysim_simple_server/data/mcc-mnc-list.json`, MIT; MVNO entries are hidden — the picker lists real networks), and `--mcc-mnc-list` overrides it.
|
||||
|
||||
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
|
||||
**Network state monitor** — a compact **Network state** panel next to the simulation buttons shows what the card currently holds and what was last simulated. Its header carries the **simulated service state** (*Undefined* until a scenario or a Location status event sets it, then *Normal service* / *Limited service* / *No service*) with a *PLMN not allowed* marker when the location files or EF.FPLMN show a rejection, plus the current location: PLMN, country and operator, the LAI/RAI/TAI, and the **roaming class** (*Home* when the PLMN equals the HPLMN, *Home equivalent* when it is in EF.EHPLMN, otherwise *Guest*). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (`init`, `write`, `read`, `refresh`); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a `… +N` counter). The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event, and never polls the card — **Refresh** re-reads all files on demand.
|
||||
|
||||
---
|
||||
**eSIM LPA** — for an eUICC (SGP.22/SGP.32) the **eSIM LPA** pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact): **Chip** (EID, EUICCInfo1/2 with decoded capability bit lists, card resources, CI PKI identifiers, category, forbidden PPRs, certification data; configured default SM-DP+ / root DS addresses; the rules authorisation table from ES10b GetRat), **Profiles** (state, nickname, provider, ICCID, ISD-P AID, class, owner, and the icon image at its natural size with its type/byte size; **Enable**/**Disable** switches a profile — the card usually sends REFRESH first and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list) and **Notifications** (read-only pending list). No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used; a non-eUICC card is reported as such.
|
||||
|
||||
## SCP81
|
||||
|
||||
The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) and has two pills: **Listener** and **Scripts**.
|
||||
|
||||
The **Listener** starts/stops the target the card's BIP channel is redirected to, in one of four modes:
|
||||
|
||||
- **PSK TLS server** (default) — a PSK TLS listener on **Host:Port** that answers with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (`X-Admin-*` headers, `200` with a command string or `204 No Content`). PSK keys come from the card presets (**Cards** tab): the key is picked by the identity the card sends in the handshake, and Start is refused when no preset has both parts. Keys are never stored or logged; an unrecognised identity is logged as `tls-psk-unknown`.
|
||||
- **Redirect to external server** — no local listener: every BIP channel is connected to the configured target (Host and Port required), which terminates TLS and runs the administration dialog; the address the card requests is only logged.
|
||||
- **Pass-through (card destination)** — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (`Other address` + transport port, TCP client remote only; no spec default port, so an incomplete request fails the channel). TLS is terminated by that platform, so the server's network is used (lab only).
|
||||
- **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.
|
||||
|
||||
The **Options** block (collapsed by default; a `custom` marker appears when anything differs from the reference defaults; applied at **Start**, remembered in the browser) exposes the HTTP framing (Chunked body / chunk size, 0 = one TLS record / Connection header / compact headers, legal per RFC 7230 §3.2 / Next-URI checkbox + template, unchecked = one-shot), the script framing (indefinite `AE 80 …` or definite `AA` Command Scripting template, comprehension-required tags, optional `X-Admin-Targeted-Application` with its own checkbox) and **Show link events** (all modes, TS 102 223 §7.5.11). The connection stays open between POSTs and closes cleanly at the 204. TLS has no settings: the listener offers TLS 1.0–1.2 and all PSK suites and lets the card negotiate; the log and state line report the version/cipher actually used, and a TLS/cipher handshake failure is logged as `tls-handshake-failed`.
|
||||
|
||||
**Script** selects the command list served over the session: **None** (leave the server's configured script) or one of the scripts created in the **Scripts** pill; **Restart script** re-queues the selected script with `force`, starting over from the first APDU.
|
||||
|
||||
The state line shows the listener, the negotiated identity and live channels (bytes in/out); **Script results (R-APDUs)** lists each served C-APDU with its R-APDU and SW (`done/total` progress); the **HTTP OTA log** records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). The same controls are available through `POST /api/scp81/bip` and `GET /api/scp81/script` (see `docs/api.md`).
|
||||
|
||||
### Scripts
|
||||
|
||||
The **Scripts** pill manages named APDU lists stored in `localStorage` (`simple_scripts`) and sent to the server when a listener starts. Each list is a sequence of hex C-APDUs, one per line (`#`/`;` comments allowed). **New** creates one from a template:
|
||||
|
||||
| Template | What it builds |
|
||||
|---|---|
|
||||
| **Empty** | an empty list |
|
||||
| **Explore ISD** | the reference administration sequence (`GET DATA FF21`, GET STATUS ISD/ELF/application listings, `GET DATA 0085`); long listings auto-continue through `SW 6310/CAFE` pages |
|
||||
| **Install from .cap** | INSTALL [for load] → LOAD ×N → INSTALL [for install] from a `.cap` (optional SD AID, install/STK parameters, make selectable) via `POST /api/scp81/gen-install`; the file is only used to generate the APDUs — it is not stored, not even its name |
|
||||
| **Delete AID** | DELETE APDUs from an AID list (one per line) with P2 = object only / object and related objects |
|
||||
|
||||
The table lists each script with kind, APDU count and creation time; **Edit** opens the name + APDU editor and **Delete** removes it. Over a session the server serves one C-APDU per card POST and tracks execution: the card reports status in its next POST (`X-Admin-Script-Status`), a session that dies resends only the unexecuted APDUs (`X-Admin-Resume` continues, a fresh dialog restarts), and a completed script is closed with `204 No Content`. The Remote APDU → **RAM/GP** builder can feed a command chain straight into the run with **Queue in SCP81**.
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
|
||||
SIMple is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
|
||||
|
||||
- Service worker pre-caches all assets on first visit
|
||||
- App icons at 192×192 and 512×512
|
||||
|
||||
## Server (pysim-otaman-server)
|
||||
## Theme
|
||||
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the OTAMan PWA (from `frontend/`) and a JSON API under `/api/*`.
|
||||
A dark theme is included. It follows the system preference and can be toggled manually with the header button (🌙/☀️); the choice is stored in `localStorage`.
|
||||
|
||||
## Localization
|
||||
|
||||
The interface is in English with Russian support. The language is detected from `navigator.language`; the header toggle (EN/RU) stores the choice in `localStorage`. Switching the language also re-renders visible dynamic views (profile lists, check reports, snapshots, cards, proactive views).
|
||||
|
||||
## Server (pysim-simple-server)
|
||||
|
||||
The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) and serves both the SIMple PWA (from `frontend/`) and a JSON API under `/api/*`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.8+** with `pip`, and **Git**
|
||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux)
|
||||
- **Windows** — use Python 3.10–3.13 (3.13 recommended): `pyscard` ships precompiled wheels for these versions. On 3.9 / 3.14 it builds from source (needs MSVC C++ Build Tools). The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
||||
- **Python 3.9+** with `pip`, and **Git** — the app runs on 3.9+, but the **SCP81 TLS-PSK listener needs Python 3.13+** (`ssl`'s PSK callbacks).
|
||||
- **Smart card reader** (PC/SC or serial/FTDI) — PC/SC is preferable (`pcsc-lite` + `ccid` on Linux). `pyscard` has no Linux wheels, so it builds from source: install the PC/SC header package too (`libpcsclite-dev` on Debian/Ubuntu, `pcsc-lite-devel` on Fedora; on Arch `pcsclite` includes them).
|
||||
- **Windows** — use **Python 3.13 (strongly recommended)**: `pyscard` has precompiled wheels up to 3.13 and the SCP81 TLS-PSK listener needs 3.13+. On 3.14 `pyscard` and some pysim dependency wheels are not available and build from source (needs MSVC C++ Build Tools). 3.9–3.12 run the app but not the SCP81 PSK listener. The SMPP bridge (`smpp.twisted3`) is intentionally skipped on Windows.
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | What it does |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning. |
|
||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
||||
| `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). |
|
||||
| `update.sh` / `update.bat` | Updates a packaged install in place: fetches the latest release package, replaces the app files and re-installs the app into the existing `.venv` (pysim is kept). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` auto-detects the reader (PC/SC if `pcscd` is running, else `/dev/ttyUSB0`); `start.bat` always uses `-p 0` (PC/SC is built into Windows). If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
||||
`start.sh` asks for the first PC/SC reader when `pcscd` is running; otherwise the server still auto-detects a PC/SC reader itself, and with none present pysim falls back to its serial default `/dev/ttyUSB0` (pass `-d <device>` for a serial/FTDI reader). `start.bat` always passes `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button.
|
||||
|
||||
### Manual installation
|
||||
### Manual installation (Linux/macOS)
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS (Windows: .venv\Scripts\activate)
|
||||
source .venv/bin/activate
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
pip install -e . # editable — serves frontend/ from the source tree
|
||||
pysim-otaman-server --http-port 8080
|
||||
pysim-simple-server --http-port 8080
|
||||
```
|
||||
|
||||
On Windows use `setup.bat` instead: it installs pysim's dependencies from
|
||||
`requirements-pysim.txt` and pysim itself with `--no-deps`, so the SMPP bridge (and
|
||||
its MSVC build chain) is not pulled in.
|
||||
|
||||
### CLI options
|
||||
|
||||
| Option | Description |
|
||||
@@ -566,34 +783,48 @@ pysim-otaman-server --http-port 8080
|
||||
| `-d` / `--device` | Serial device path |
|
||||
| `--no-card-init` | Skip card init to preserve the CAT session (no file manager) |
|
||||
| `--apdu-trace` | Log APDU-level traces to stderr |
|
||||
| `--gsmtap [HOST[:PORT]]` | Stream the card APDUs as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. APDUs only (no ATR), emitted in wire shape (case-4 Le stripped, data as a GET RESPONSE TPDU, case-2/3 responses merged into the command packet). Combines with `--apdu-trace` |
|
||||
| `--log-requests` | Log request/response payloads to stderr |
|
||||
| `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default 10-byte GSM profile) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s) |
|
||||
| `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) |
|
||||
| `--poll-interval` | Idle interval before automatic STATUS polling (default 30s; `0` disables polling) |
|
||||
| `--mcc-mnc-list` | Override the bundled worldwide MCC/MNC operator list (`pysim_simple_server/data/mcc-mnc-list.json`) |
|
||||
| `--full-pysim-init` | Use pysim's stock init/equip (redundant card resets). The default init/equip is reset-free — only explicit equip/reset reconnect the card |
|
||||
| `--no-auto-equip` | Do not initialize a card automatically right after it is inserted (default: auto-equip on) |
|
||||
| `--menu-timeout` | Auto-answer a paused STK command with a timeout TERMINAL RESPONSE (default 60s; `0` disables) |
|
||||
| `--timing` | Log phase durations, card resets and APDU counters with elapsed timestamps |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` isn't running or the user lacks permission: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — no serial reader; connect a USB reader or pass `-d` explicitly. The server still starts without a reader.
|
||||
- **"Service not available" (0x8010001D) / reader gone after a `pcscd` restart** — the server recovers by itself: a watchdog revives pyscard's presence monitor and the next equip (automatic after re-insertion, or the **Equip** button) recreates the PC/SC transport (releasing the old link first). No server restart needed.
|
||||
- **"Card was removed" errors / auto-equip fails after a card swap** — a normal card swap reconnects on the existing transport (card-level PC/SC errors no longer force a transport rebuild), a failed auto-equip is retried up to 3 times, and the watchdog re-arms it every 5 s while a card is present. A removed card also clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||
|
||||
### API reference
|
||||
|
||||
See [docs/api.md](docs/api.md) for the full endpoint reference.
|
||||
|
||||
## Theme
|
||||
|
||||
Dark theme is supported. The app follows the OS preference on first visit, and a manual toggle button (🌙/☀️) at the top-right corner persists the choice in `localStorage`.
|
||||
|
||||
## Localisation
|
||||
|
||||
The UI is in English with Russian language support. Language is detected from the browser's `navigator.language` preference. A manual toggle button (EN/RU) in the header persists the choice in `localStorage`.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
| PWA (OTAMan) | Server | Status |
|
||||
| PWA (SIMple) | Server | Status |
|
||||
|-------------|--------|--------|
|
||||
| 1.x.x | 1.x.x | ✅ Compatible |
|
||||
| 1.x.x | 0.x.x | ❌ Outdated — update server |
|
||||
| 1.x.x | 2.x.x+ | ⚠️ Server newer — update PWA |
|
||||
| any | same major | ✅ Compatible |
|
||||
| any | older major | ❌ Outdated — update server |
|
||||
| any | newer major | ⚠️ Server newer — update PWA |
|
||||
|
||||
The PWA checks the server version on connect via `GET /api/version` and warns if versions are incompatible.
|
||||
The PWA checks the server version on connect via `GET /api/version` and compares the major version (e.g. a 3.x PWA with a 3.x server; a 2.x server is flagged as outdated).
|
||||
|
||||
## License
|
||||
|
||||
**GPL-2.0-or-later** — see [LICENSE](LICENSE). This is the same license as
|
||||
[pySim](https://github.com/osmocom/pysim), whose library the server imports, and
|
||||
as [simtrace-analyser](https://github.com/anttro/simtrace-analyser), from which
|
||||
some client-side decoders were ported.
|
||||
|
||||
pySim itself is not included in this repository or in the release artifacts:
|
||||
`setup.sh` / `setup.bat` install it from its upstream git repository.
|
||||
|
||||
Bundled third-party components (the MCC/MNC operator list and the AES/DES
|
||||
bundles in the PWA) keep their own MIT licenses — see
|
||||
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||
|
||||
+313
-79
@@ -1,23 +1,51 @@
|
||||
# OTAMan — SIM OTA toolkit: PWA + локальный сервер карт
|
||||
# SIMple — мастерская для UICC/SIM: APDU · OTA · STK · профилирование
|
||||
|
||||
OTAMan — автономный offline-PWA (HTML/JS) для создания APDU-команд (SIM, USIM, GlobalPlatform RAM), сборки защищённых пакетов SCP80 по ETSI TS 102 225 и построения Expanded Remote Application data format APDU по ETSI TS 102 226. В комплекте — [`pysim-otaman-server`](pysim_otaman_server/) — локальный HTTP-сервер поверх pySim для работы с картой: файловый менеджер, сырые APDU, меню SIM Toolkit и доставка OTA.
|
||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
||||
|
||||
**Демо:** [otaman.atroshin.ru](https://otaman.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
|
||||
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
|
||||
|
||||
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
||||
- **OTA-лаборатория** — защищённые пакеты SCP80 (TS 102 225) и установка Java Card RAM, а также терминальная сторона HTTP OTA (SCP81): эмуляция BIP-каналов, PSK-TLS-сервер администрирования и выполнение скриптов APDU;
|
||||
- **Симулятор** — браузер меню SIM Toolkit, обработка proactive-команд и событий, редактирование TERMINAL PROFILE, тест-скрипты карты и журнал C-AT-диалога;
|
||||
- **Профайлер карты** — захват файловой системы (профили и неизменяемые снимки с таймингами), точное сравнение снимков и декодер FCP/FCI.
|
||||
|
||||
**Демо:** [simple.atroshin.ru](https://simple.atroshin.ru) — только PWA, для экспериментов. Для функций картридера установите сервер (ниже).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
**Только PWA (клиентские функции):** откройте `frontend/index.html` в любом браузере или раздайте `frontend/` любым статическим сервером. Python не нужен.
|
||||
|
||||
**Полная установка (PWA + сервер карт):**
|
||||
**Полная установка (PWA + сервер карт)** — два способа:
|
||||
|
||||
**1. Скачать релизный пакет (рекомендуется).** На странице
|
||||
[Releases](https://github.com/anttro/simple/releases) возьмите последний пакет
|
||||
(`simple-<version>.zip`), распакуйте в любое место и один раз запустите setup:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
./setup.sh # или setup.bat в Windows — создаёт .venv, ставит pysim + сервер
|
||||
./start.sh # или start.bat — запускает сервер (он же раздаёт PWA)
|
||||
```
|
||||
|
||||
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны.
|
||||
Пакет собран из помеченного тегом исходного кода — без истории git и без файлов для
|
||||
разработки. Git всё равно нужен: `setup` ставит **pysim** из его upstream-репозитория
|
||||
(ни pysim, ни его зависимости в пакет не входят).
|
||||
|
||||
**Обновление быстрое:** `./update.sh` (или `update.bat`) скачивает последний релизный
|
||||
пакет, заменяет файлы приложения на месте и переустанавливает приложение в существующий
|
||||
`.venv` — pysim и его зависимости остаются, ничего не пересобирается.
|
||||
`./update.sh --check` показывает текущую и последнюю версии, `--version X.Y.Z` —
|
||||
конкретный релиз, `--zip file.zip` — обновление из локального пакета.
|
||||
|
||||
**2. Клонировать репозиторий (для разработки или ещё не выпущенных изменений).**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/anttro/simple.git # или --depth 1: без истории,
|
||||
cd simple # `git pull` всё равно обновляет
|
||||
./setup.sh # или setup.bat в Windows
|
||||
./start.sh # или start.bat
|
||||
```
|
||||
|
||||
Затем откройте http://127.0.0.1:8080 — интерфейс и API на одном origin, поэтому CORS и разрешения браузера не нужны. Сервер запускается и с пустым картридером (отсутствие карты — нормальное состояние): вставьте карту, и она инициализируется автоматически (auto-equip), либо нажмите **Equip card** в интерфейсе.
|
||||
|
||||
## Сборка
|
||||
|
||||
@@ -31,13 +59,13 @@ npm run build
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Четыре вкладки: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. Вкладки C-APDU и SCP80 имеют подвкладки.
|
||||
Семь вкладок верхнего уровня: **Remote APDU**, **SCP80**, **SCP81**, **Cards («Карты»)**, **Profiler**, **Card reader** и **Simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; вкладка **SCP81** — пиллы **Listener** и **Scripts**; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||
|
||||
---
|
||||
|
||||
## Вкладка C-APDU
|
||||
## Вкладка Remote APDU
|
||||
|
||||
Построение команд APDU (C-APDU). Пять подвкладок для разных поколений карт и наборов команд.
|
||||
Построение команд APDU (C-APDU). Шесть подвкладок для разных поколений карт, наборов команд и инструментов разбора: **SIM RFM**, **USIM RFM**, **RAM/GP**, **Expanded Script**, **«Пуш/триггер»** и **«Разбор»** (подвкладки **«Разбор C-APDU»** / **«Разбор R-APDU»**).
|
||||
|
||||
### SIM RFM
|
||||
|
||||
@@ -57,6 +85,9 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| DEACTIVATE FILE | 04 | Деактивация файла |
|
||||
| VERIFY PIN | 20 | Проверка PIN1 или PIN2 |
|
||||
| CHANGE PIN | 24 | Смена PIN1 или PIN2 |
|
||||
| UNBLOCK PIN | 2C | Разблокировка PIN через PUK |
|
||||
| SEARCH RECORD (SEEK) | A2 | Поиск образца в линейном фиксированном EF; P2 задаёт тип 1/2 и режим (TS 151 011 §9.2.7) |
|
||||
| INCREASE | 32 | Прибавить значение к последней увеличенной/обновлённой записи циклического EF (3 байта, TS 151 011 §9.2.8) |
|
||||
|
||||
#### Методы SELECT
|
||||
|
||||
@@ -67,6 +98,15 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
| By DF name / AID | 04 | 00 | AID |
|
||||
| ADF RFM chain | 00 | 00 | FID через запятую |
|
||||
|
||||
#### Выбор файла для SELECT
|
||||
|
||||
У каждой строки SELECT есть выбор файла: поле фильтра, переключатель **all files**, корень начала сессии с текущим DF и список файлов по корням (`MF`, `ADF.USIM`, ...). Выбор файла заполняет поля текущего метода; если метод не может выразить выбор, помощник переключается на подходящий - предпочитая **path** (один SELECT), затем **chain** - и сообщает об этом, например `7F106F40` (путь от MF) или `7F20,6F46` (относительная цепочка FID).
|
||||
|
||||
- **Источник списка**: при подключённой карте приоритет у загруженного дерева файлового менеджера (имена карты, проверенное наличие; отсутствующие скрываются). Без карты и для незагруженных ветвей используется поставляемый стандартный список: `uicc_files.json`, собранный из профилей/классов приложений pySim скриптом `pysim_simple_server/uicc_files.py` и проверяемый `tests/test_uicc_files.py`. До запуска **«Проверить все файлы»** список соответствует спецификациям. Пользовательские файлы добавляются всегда.
|
||||
- **Начало сессии**: неявный текущий DF зависит от TAR, на который отправляется защищённый пакет (UICC Shared File System RFM -> MF, TS 102 226 §7.2; ADF RFM -> свой ADF, §7.3). По умолчанию: MF для обоих конструкторов; меняется для каждой строки (выберите ADF для работы в сессии ADF).
|
||||
- **Path** - последовательность FID от MF без идентификатора MF (ISO 7816-4); вариант USIM «from current DF» использует относительный хвост. **Chain** остаётся относительным к текущему DF и следует порядку поиска FID из TS 102 221 §11.1.1.2 (дети, родитель, соседи).
|
||||
- Корни ADF не выбираются - приложение выбирается по AID, и метод **By AID** остаётся ручным (TS 102 226 §7.1 запрещает P1=`04` для RFM).
|
||||
|
||||
#### Опции
|
||||
|
||||
- **Start with SELECT** — добавить SELECT перед командой.
|
||||
@@ -84,6 +124,18 @@ CLA = `A0` (GSM 11.11 / ISO 7816-4).
|
||||
|
||||
CLA = `00` (ETSI TS 102 221). Те же команды, что и SIM, но SELECT использует P1=09, P2=0C.
|
||||
|
||||
Дополнительно доступны административные команды UICC (ETSI TS 102 221/102 222):
|
||||
|
||||
| Команда | INS | Описание |
|
||||
|---|---|---|
|
||||
| SEARCH RECORD | A2 | Простой (P2=10/12, P1 = номер записи) и расширенный (P2=18, 2-байтовое indication) поиск по TS 102 221 §11.1.7 |
|
||||
| INCREASE | 32 | Прибавить значение к записи циклического EF; необязательный SFI в P1 (TS 102 221 §11.1.8) |
|
||||
| CREATE FILE | E0 | Создание EF из шаблона FCP; кнопка **Build template** собирает структурный каркас (TS 102 222 §6.3) |
|
||||
| DELETE FILE | E4 | Удаление EF/DF по FID (TS 102 222 §6.4) |
|
||||
| RESIZE FILE | D4 | Изменение размера файла (FCP с FID + новый размер); CLA `80` по TS 102 222 Table 1 (TS 102 222 §6.10) |
|
||||
| SET DATA | DB | Запись BER-TLV объекта; P2 выбирает первый/следующий/повторный блок (+SFI) (TS 102 221 §11.3.2) |
|
||||
| RETRIEVE DATA | CB | Чтение BER-TLV объекта или списка тегов (`5C`); P2 как выше (TS 102 221 §11.3.1) |
|
||||
|
||||
#### Ссылки
|
||||
|
||||
- ETSI TS 102 221: UICC-Terminal Interface
|
||||
@@ -102,11 +154,17 @@ CLA = `00` (ETSI TS 102 221). Те же команды, что и SIM, но SELE
|
||||
|
||||
| Тип | Тег | Описание |
|
||||
|---|---|---|
|
||||
| C-APDU | 22 | APDU |
|
||||
| C-APDU | 22 | Любая команда удалённого управления: hex или собранная редакторами SIM RFM / USIM RFM / RAM-GP (TS 102 226 §5.2.1.0) |
|
||||
| Immediate Action | 81 | Proactive-команда или action indicator |
|
||||
| Error Action | 82 | Proactive-команда при ошибке |
|
||||
| Script Chaining | 83 | Данные для многопакетных скриптов |
|
||||
|
||||
#### Строки C-APDU
|
||||
|
||||
Строка C-APDU начинает в режиме **Hex** (вставьте любую команду) и может переключиться на одно-строчный редактор **SIM RFM**, **USIM RFM** или **RAM/GP**, который собирает команду из полей — итоговый APDU оборачивается в TLV `22` и показывается рядом с выбором. GET RESPONSE там не предлагается: расширенный формат не несёт GET RESPONSE (TS 102 226 §5.2.1.1). Кнопка **→ Расширенный скрипт** в представлениях SIM RFM / USIM RFM / RAM-GP добавляет их команды строками C-APDU (строки GET RESPONSE отбрасываются).
|
||||
|
||||
Только REFRESH, DISPLAY TEXT и PLAY TONE допустимы как Immediate Action (TS 102 226 Table 5.5), и только DISPLAY TEXT и PLAY TONE — как Error Action (Table 5.9); поведение карты для других проактивных команд не определено.
|
||||
|
||||
#### Сборщик Immediate Action
|
||||
|
||||
- **Action indicator**: `81` / `82`
|
||||
@@ -138,9 +196,13 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
| GET DATA | CA | tag | Чтение объектов данных |
|
||||
| STORE DATA | E2 | 00/40/80/C0 | Запись данных |
|
||||
| SET STATUS | F0 | 80/40/60 | Управление жизненным циклом |
|
||||
| PUT KEY | D0 | key ver | Замена ключа: P1 = версия ключа, P2 = идентификатор, данные = новый ключевой материал (GP Card Spec §11.8) |
|
||||
| PUSH | EC | 01 | Просьба приложению открыть BIP-канал / CAT_TP / TCP или отправить пакет идентификации (TS 102 226 §9) |
|
||||
| EXTERNAL AUTHENTICATE | 82 | 00 | Аутентификация SCP |
|
||||
| INTERNAL AUTHENTICATE | 88 | 00 | Challenge-response |
|
||||
|
||||
**PUSH** (TS 102 226 §9.2) — это C-APDU (`80 EC 01 P2`), адресованный приложению, поддерживающему BIP и/или CAT_TP (не триггер HTTP-администрирования — тот собирается в конструкторе HTTP OTA выше). P2: `01` открытие BIP-канала (COMPREHENSION-TLV OPEN CHANNEL, необязательно), `02` CAT_TP (порт назначения в transport level `3C` с типом протокола 00, необязательные buffer size `39` / channel data `36`), `03` TCP (bearer `35`, transport level `3C` с типом протокола 02 = TCP-клиент, удалённое соединение, адрес назначения `3E`, NAA/APN `47`), `04` пакет идентификации (channel data `36`; при отсутствии используется ICCID). Проактивную OPEN CHANNEL выдаёт само приложение; успех — `90 00`, отказ — `6F 00` с Result TLV в данных ответа, некорректные данные отклоняются с `6A 80`.
|
||||
|
||||
#### Привилегии (INSTALL [for install])
|
||||
|
||||
Три байта привилегий по GP Spec Tables 11-7, 11-8, 11-9.
|
||||
@@ -286,11 +348,59 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
- ETSI TS 102 225
|
||||
- pySim: enc_imsi()
|
||||
|
||||
### Разбор C-APDU
|
||||
|
||||
Вставьте сырой hex APDU — отобразится раскрывающееся дерево. Контейнер определяется автоматически: **Expanded Script** (ведущие `AA` или `AE80`, разбор по ETSI TS 102 226 §5.2.1) или **компактная цепочка C-APDU** (последовательность ISO 7816 C-APDU). У каждого узла — метка, hex и краткое описание; родительские узлы раскрываются до подэлементов.
|
||||
|
||||
### Пуш/триггер
|
||||
|
||||
Объединяет команды, заставляющие карту выйти в сеть. Три подвкладки переключают формы — «HTTP OTA Триггер (Push SMS)», «HTTP OTA Store (параметры SD)» и «Триггер канала/линка» — показывается только выбранная (обе формы GP используют один набор полей; режим переключает заголовок, собственные строки формы — тег параметров SD в Store, Command Scripting template в Trigger — и обёртку payload'а). Доставляются они по-разному — триггер администрирования это TLV-сообщение для Security Domain, а PUSH §9 это C-APDU (`80 EC 01 P2`) для приложения с поддержкой BIP/CAT_TP.
|
||||
|
||||
#### Сессия администрирования (HTTP OTA)
|
||||
|
||||
|
||||
Сборка payload-ов Remote Application Management over HTTP по GlobalPlatform **GPC v2.2 Amendment B v1.1** (§4.7). Два режима:
|
||||
|
||||
- **Trigger (Push SMS)** — параметры запуска административной сессии (`81 > 83 > 84/[85]/[86]/89`, Table 4-3). Это сообщение просит Security Domain карты выйти в сеть и начать HTTP-сессию.
|
||||
- **Store (SD admin params)** — запись тех же параметров как данных карты (Security Domain) через **STORE DATA в TLV-режиме** (`80 E2 90 00`, P1=90 = последний блок + BER-TLV по GP v2.2 Amendment B v1.1.3), обёрнутых в тег `85` (или `A5`) по Table 4-4.
|
||||
|
||||
| Секция | Тег | Содержимое |
|
||||
|---|---|---|
|
||||
| Параметры соединения | `84` | COMPREHENSION-TLV для открытия TCP-соединения (OPEN CHANNEL по TS 102 223): Device Identities `82`, Alpha `05`, Command details `81`, Bearer `35` (`03` = bearer по умолчанию), вендорские TLV. Редактор строк + пресеты, редактируемый hex. |
|
||||
| Параметры безопасности | `85` | Table 4-6: LV PSK Identity (текст), LV Key version/KID. Идентифицирует ключ PSK TLS (RFC 4279). |
|
||||
| Политика повторов | `86` | Table 4-7: счётчик повторов (2 байта, напр. `B000`), задержка повтора как timer TLV TS 102 223 (`25 03 HH MM SS`), опциональный вендорский TLV отчёта об ошибке. |
|
||||
| HTTP POST | `89` | Tables 4-8/9/10: заголовок Host (`8A`), X-Admin-From agent ID (`8B`), URI (`8C`) — текст преобразуется в октеты. |
|
||||
|
||||
Чекбокс **Command Scripting template** оборачивает всю команду `81` в формат Expanded Remote Application с определённой длиной (`AA`, ETSI TS 102 226 §5.2.1) для TAR, обрабатывающих расширенный формат. **Pack into Secured packet** отправляет собранный payload на вкладку SCP80 для заполнения SPI/счётчика — укажите там TAR, который слушает SD (обычно OTASD).
|
||||
|
||||
#### PUSH по TS 102 226 §9
|
||||
|
||||
Подвкладка **«Триггер канала/линка»** собирает запросы §9 тем же кодировщиком, что и строка **PUSH** в цепочке RAM/GP, и предлагает **«Упаковать в Secured packet»** (TAR остаётся ручным — запрос адресован целевому приложению) и **→ Расширенный скрипт** (каждый APDU становится Command TLV `22`):
|
||||
|
||||
- **Открытие BIP-канала** (`01`) — собирается в режиме *Fields* из самых употребительных параметров OPEN CHANNEL (bearer, UDP/TCP клиент + порт, адрес назначения, NAA/APN, размер буфера, alpha; локальный адрес не отправляется), либо в режиме *Hex* с сырыми COMPREHENSION-TLV; проактивную команду OPEN CHANNEL выдаёт само приложение. Stepping Stones R7 §18.6 рекомендует отправлять его вместе с CAT_TP-запросом в одном сообщении (параметры канала в `01`, порт CAT_TP в `02`) — в форме есть флажок «Также запросить установление соединения CAT_TP (02)», пара уходит одним скриптом команд;
|
||||
- **CAT_TP** (`02`) — порт назначения в transport level `3C` с типом 00 (обязателен), необязательные buffer size `39` / данные идентификации `36`; отдельно полагается на предустановленные на карте умолчания, поэтому предпочтительна пара выше;
|
||||
- **TCP-соединение** (`03`) — bearer `35`, transport level `3C` с типом 02, адрес назначения `3E` (`21` IPv4 / `57` IPv6 / `F0` FQDN), NAA/APN `47`; это параметры OPEN CHANNEL для TCP, и они обязательны в запросе, поэтому `03` самодостаточен (сопутствующий `01` не нужен) — соединение устанавливается через BIP или прямое IP-соединение по TS 102 483, где поддерживается;
|
||||
- **Пакет идентификации** (`04`) — не триггер: ему нужен уже открытый TCP-канал, поэтому у TCP-запроса есть флажок «Также отправить пакет идентификации (04)», добавляющий его в то же сообщение; `04` также остаётся в строке PUSH цепочки для сценариев.
|
||||
- **PoR** — Stepping Stones R7 §18.6.3 рекомендует *не* запрашивать PoR при успешном push (он может помешать обмену ME–карта); запрашивайте его только для разбора сбоя, в режиме SMS-SUBMIT (бит SPI2 `0x20` в SCP80).
|
||||
|
||||
---
|
||||
|
||||
|
||||
### «Разбор R-APDU»
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Вкладка SCP80
|
||||
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **Cards** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225.
|
||||
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **RAM** и **TAR-сканер**. Сборка защищённых пакетов по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх видах SCP80.
|
||||
|
||||
### Secured Packet
|
||||
|
||||
@@ -301,7 +411,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
| Поле | Размер | Описание |
|
||||
|---|---|---|
|
||||
| CPI | 1 | Command Packet Identifier (`02`) |
|
||||
| CPL | 1 | Command Packet Length |
|
||||
| CPL | 2 (SMS) | Command Packet Length (CHL..конец, включая padding) |
|
||||
| CHI | 1 | Command Header Identifier (`01`) |
|
||||
| CHL | 1 | Command Header Length |
|
||||
| SPI | 2 | Security Parameter Indicator |
|
||||
@@ -310,7 +420,7 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
|
||||
| TAR | 3 | Toolkit Application Reference |
|
||||
| CNTR | 5 | Счётчик повторов |
|
||||
| PCNTR | 1 | Padding counter |
|
||||
| RC/CC/DS | 8 | Контрольная сумма / MAC |
|
||||
| RC/CC/DS | 4–8 | CRC (CRC-32) / контрольная сумма (MAC) / цифровая подпись |
|
||||
| Secured Data | переменная | APDU (с шифрованием при необходимости) |
|
||||
|
||||
#### SPI1 (Уровень безопасности)
|
||||
@@ -365,6 +475,10 @@ PoR подтверждает, что карта получила и выполн
|
||||
|
||||
Delivery PoR (SPI2 `01`) проще — карта возвращает PoR напрямую в ответе ENVELOPE. Submit PoR (SPI2 `21`) используется, когда карта не может ответить inline (ограничено пространство ответа ENVELOPE).
|
||||
|
||||
#### Доставка SMS
|
||||
|
||||
Защищённый пакет доставляется в ENVELOPE SMS-PP-DOWNLOAD. Его размер в байтах и число необходимых SMS показываются под полем пакета; пакет, не помещающийся в одно SMS, доставляется **конкатенированной** SMS-PP загрузкой (TS 31.115 §4.3): пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134 — первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Пакет, которому нужно больше 5 сегментов, отклоняется — это практический предел буфера конкатенации карты.
|
||||
|
||||
#### Ссылки
|
||||
|
||||
- ETSI TS 102 225 V18.1.0
|
||||
@@ -372,38 +486,21 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
- ISO 9797-1
|
||||
- NIST SP 800-38B (CMAC)
|
||||
|
||||
### Cards
|
||||
|
||||
Пилл **Cards** управляет сохранёнными конфигурациями карт (пресеты). Каждый пресет хранит криптографические ключи, настройки TAR и счётчик повторов для SCP80-операций.
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| SPI1 / SPI2 | Уровень безопасности и настройки PoR |
|
||||
| Ключ KIc / KID | Hex ключи шифрования и MAC |
|
||||
| Индекс KIc / KID | Номер версии ключа |
|
||||
| TAR | Toolkit Application Reference (3 байта) |
|
||||
| Счётчик (CNTR) | 10-значный hex счётчик повторов, автоматически увеличивается после каждой успешной отправки SCP80 |
|
||||
|
||||
**Добавить карту:** заполните имя, SPI1/SPI2, ключи KIc/KID и их индексы, TAR, нажмите **Add**. Карта появится в списке и станет доступна в выпаданом списке **Card preset** на RAM-вкладке.
|
||||
|
||||
**Редактировать карту:** выберите карту в списке, измените поля, нажмите **Save**.
|
||||
|
||||
**Удалить карту:** выберите карту, нажмите **Delete**. Удаляет пресет из `localStorage`.
|
||||
|
||||
**Счётчик:** 10-значный hex-счётчик (CNTR) автоматически увеличивается после каждой успешной отправки SCP80 (ручные отправки Secured Packet и RAM-операции). Обновлённый счётчик автоматически сохраняется обратно в пресет.
|
||||
|
||||
### RAM
|
||||
|
||||
Все операции RAM отправляются как защищённые пакеты SCP80 (ETSI TS 102 225) через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES) для безопасной транспортировки.
|
||||
|
||||
Выберите сохранённую конфигурацию карты из выпадающего списка **Card preset**. Если пресет не выбран, RAM-вкладка предупреждает и отказывается выполнять.
|
||||
|
||||
В RAM-подвкладке доступны две операции через выпадающий список **Operation**:
|
||||
В RAM-подвкладке доступны пять операций через выпадающий список **Operation**:
|
||||
|
||||
| Операция | Описание |
|
||||
|---|---|
|
||||
| **Explore Card (all GP data)** | Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками **Delete** для каждого элемента. |
|
||||
| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
|
||||
| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. Load-файл делится на LOAD APDU по 1–240 байт полезной нагрузки (по умолчанию 240, изменяется в **размер блока LOAD**); защищённый пакет больше одного SMS доставляется конкатенированной SMS-PP загрузкой до 5 сегментов. |
|
||||
| **CAP compatibility test** | Отвечает, может ли карта удовлетворить список импортов CAP, ничего не устанавливая: сервер выполняет проверку формата, INSTALL\[for load\] и блоки LOAD только до блока, завершающего компонент **Import** (где JCRE проверяет список импортов), и останавливается — ничего не фиксируется, удалять нечего. Список **«Версии импортов для проверки»** предзаполняется из анализа CAP (`AID=version` переписывает версию, `+AID=version` добавляет синтетический запрос возможностей, **«Все 0.0»** снимает требования); нажмите «Все 0.0», затем возвращайте строки по одной, чтобы найти отсутствующий пакет и версию. Проверяется только этап LOAD/импортов. |
|
||||
| **INSTALL \[for install\] (loaded package)** | Один APDU INSTALL \[for install\] для уже загруженного на карту пакета (Load File AID + Module AID, привилегии, параметры установки/STK, необязательное make selectable) — параметры можно перебирать без повторной загрузки `.cap`. |
|
||||
| **INSTALL \[for make selectable\]** | Один APDU INSTALL \[for make selectable\] (AID экземпляра) для установленного, но невыбираемого экземпляра апплета. |
|
||||
|
||||
#### Обзор карты (Explorer View)
|
||||
|
||||
@@ -417,56 +514,135 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
---
|
||||
|
||||
## Вкладка Response parser
|
||||
### TAR-сканер
|
||||
|
||||
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
|
||||
Третья пилюля вкладки SCP80 показывает, **какие OTA-приложения зарегистрированы на карте**: отправляется по одному
|
||||
защищённому пакету на каждый выбранный TAR — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1
|
||||
(предзаполнены, все отмечены) плюс свои TAR, добавляемые кнопкой **Добавить TAR** и хранимые в `localStorage` — с
|
||||
безобидной C-APDU (по умолчанию `SELECT MF`), пресетом и keyset этого представления и SPI `16/01` (проверка
|
||||
счётчика + PoR). Каждый ответ классифицируется: **зарегистрировано** (ENVELOPE 9000 + PoR со SW и данными
|
||||
приложения), **принято, нет ответа** (PoR без R-APDU), **принято, нет PoR** (пакет молча отброшен) и
|
||||
**отклонено** (ENVELOPE отклонён). Каждый принятый картой пакет расходует счётчик, который сохраняется в keyset
|
||||
пресета; проба отклоняется, если в SPI1 нет проверки счётчика или в пресете нет такого номера keyset.
|
||||
`POST /api/tar-probe` (см. `docs/api.md`).
|
||||
|
||||
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
|
||||
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
|
||||
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
|
||||
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
|
||||
---
|
||||
|
||||
## Карты
|
||||
|
||||
Хранит сохранённые конфигурации карт (пресеты) в **серверном хранилище** — `~/.pysim-simple-server/card_presets.json` (переопределяется `--card-presets`). Пресет содержит криптографические ключи, настройки SPI, TAR-ы и счётчики повторов для SCP80-операций, необязательный ключ **ADM**, а также пару **PSK identity / PSK key** для слушателя SCP81 HTTP OTA. Пресет содержит **несколько keysets** — номер keyset задаётся нибблом `b8..b5` его KIc/KID (TS 102 225 §5.1.2/A.2): KIc и KID одного keyset должны нести один и тот же номер (`01`–`0F`; A.2 отвергает несовпадение), `00` («без защиты») не является keyset пресета, и у каждого keyset на карте свой счётчик (Annex A.1) — счётчиками владеет сервер: он сохраняет каждый израсходованный инкремент и пишет его в журнал аудита (`card_presets.json.audit.jsonl`). Список загружается при подключении PWA; экспорт/импорт JSON (принимает и старые плоские пресеты) — путь миграции из браузерного хранилища. «Карты» — **верхнеуровневая вкладка**. Поля формы сгруппированы в два блока с рамкой — **SCP80 (GSM 03.48, ETSI TS 102 225)** и **SCP81 (HTTP OTA)**; необязательное поле **ADM** находится в верхней строке, а keysets редактируются строками (по одной на номер, **Добавить keyset** / **Удалить keyset**). При подключении карты читается её EF.ICCID, и пресет с тем же ICCID автоматически выбирается во всех трёх видах SCP80; если подключённая карта не совпадает ни с одним пресетом — или выбранный пресет принадлежит другой карте — все три вида SCP80 показывают жёлтое предупреждение над выбором пресета. В шапке отображаются серые маркеры **SCP80** / **SCP81** и значок ключа на бейдже **ADM**, когда в пресете с ICCID подключённой карты эти поля заполнены.
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| Name | Понятная метка (обязательна) |
|
||||
| ICCID | Опциональный идентификатор карты; кнопка **С карты** подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается) |
|
||||
| ADM | Необязательный администраторский PIN (hex или до 8 ASCII-цифр), сохраняется для файлового менеджера; представлениями SCP80/SCP81 не используется |
|
||||
| PIN1 / PUK1 / PIN2 / PUK2 | Необязательные коды карты, сохраняются для будущего использования (4–8 десятичных цифр; пока никем не используются) |
|
||||
| Релиз CAT | Необязательный релиз CAT (USAT) карты (4–18; пусто = не указан, трактуется как Rel-12) — механизм релизов скрывает элементы CAT, появившиеся позже (параметры событий Симулятора) |
|
||||
| SPI1 / SPI2 | Уровень безопасности и настройки PoR |
|
||||
| Индекс KIc / KID | Номер keyset (`01`–`0F`, из байта KIc/KID; задаётся вместе с ключами) |
|
||||
| Ключ KIc / KID | Hex-ключи шифрования и MAC |
|
||||
| ISD TAR | TAR Issuer Security Domain (TS 101 220 Annex D), используется для операций RAM/GP; умолчание `000000` |
|
||||
| UICC RFM TAR | TAR UICC Shared File System RFM (TS 102 226 §7.2), используется в представлении SIM RFM; умолчание `B00000` |
|
||||
| ADF RFM TAR | TAR ADF RFM (TS 102 226 §7.3), привязан к AID приложения (ADF.USIM в представлении USIM RFM); умолчание `B00001` |
|
||||
| Счётчик (CNTR) | 10-значный hex-счётчик повторов, свой у каждого keyset; сервер увеличивает и сохраняет его после каждой операции, которая его расходует (при SPI1 b5 b4 = `00` счётчик не ведётся, TS 102 225 §5.1.1) |
|
||||
| PSK identity | SCP81 HTTP OTA: идентификатор, который карта присылает в TLS-рукопожатии |
|
||||
| PSK key | SCP81 HTTP OTA: 32 hex-символа (16 байт); ключ выбирается по идентификатору, который предъявляет карта |
|
||||
|
||||
Столбец **SCP81** показывает, задана ли в пресете рабочая пара PSK: **✓** (идентификатор и ключ), **⚠** (только одно из двух — слушатель такой пресет игнорирует), **—** (PSK нет). Идентификатор и ключ задаются вместе.
|
||||
|
||||
**Добавить карту:** заполните имя, ICCID (опционально — кнопка **С карты** подставляет EF.ICCID подключённой карты), необязательный **ADM**, SPI1/SPI2, один или несколько keysets (KIc/KID + ключи на номер, **Добавить keyset**), три TAR-а, при необходимости пару PSK и нажмите **Add**. Дубликат ICCID (сравнение без пробелов и с учётом сырой hex-формы) отклоняется с указанием конфликтующего пресета. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM.
|
||||
|
||||
**Изменить/удалить:** **Edit** загружает пресет в форму (кнопка Add становится **Save**; **Cancel** очищает форму); **Remove** удаляет пресет из серверного хранилища. Успешная отправка SCP80 увеличивает и сохраняет счётчик использованного keyset, а изменения сразу передаются работающему слушателю SCP81.
|
||||
|
||||
---
|
||||
|
||||
## Card Reader (интеграция с pySim)
|
||||
|
||||
Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой.
|
||||
Подключение к встроенному [`pysim-simple-server`](pysim_simple_server/) для работы с картой.
|
||||
|
||||
> **Ограничение браузера:** если PWA раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-otaman-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
|
||||
> **Ограничение браузера:** если PWA раздаётся с публичного HTTPS-хоста, для доступа к локальному серверу (`http://127.0.0.1:8080`) нужны два условия: сервер должен отправлять `Access-Control-Allow-Private-Network: true` (pysim-simple-server ≥ 1.6.1 делает это автоматически), и браузеру должно быть разрешено обращаться к локальной сети — в Chrome/Edge/Vivaldi: Настройки сайта → Доступ к локальной сети → разрешить сайт (или подтвердить запрос). Без разрешения браузера запрос к `127.0.0.1` блокируется ещё до отправки preflight.
|
||||
|
||||
### Файловый менеджер
|
||||
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
|
||||
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). При выборе файла открывается панель деталей: FID, тип файла, размер / структура записей и декодированный FCI (в блоке с рамкой и именем файла) над панелью содержимого. Когда FCI ссылается на свои правила доступа через запись EF.ARR (атрибут безопасности `8B`), правила указанной записи показываются отступом под этой строкой — они берутся из кэшированных копий EF.ARR карты (MF, DF.TELECOM/DF.GSM и ADF.USIM, по цепочке DF файла; снимок разрешает их из собственной захваченной копии).
|
||||
|
||||
- **Read** — чтение файла (автоопределение transparent/record)
|
||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы закреплены над деревом вместе с **«Проверить все файлы»**, выбор сохраняется в `localStorage`)
|
||||
- **Read raw** / **Read decoded** — чтение выбранного файла как hex-дампа или как таблицы декодированных полей (клиентские декодеры: IMSI, ICCID, SPN, списки PLMN, LOCI/EPSLOCI, ADN/MSISDN, таблицы сервисов, SUME, …); подсвеченная пилла — текущий вид, клик по любой из них перечитывает файл. Серверный pySim JSON того же чтения остаётся в свёрнутом блоке *pySim JSON (server)*; **Read decoded** отключается, когда клиентские декодеры не покрывают файл (определяется по имени/FID до чтения)
|
||||
- **Edit raw** — редактирование сырых hex-данных и **Save** для записи (или **Cancel**); для transparent-файлов — textarea, для record-файлов — по полю на запись с флажками выбора. Декодированный вид только для чтения — **Edit raw** сначала переключается на hex и при необходимости читает файл
|
||||
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым. Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||
- **ADM** — файлы, требующие администраторский PIN, возвращают `6982`/`9804`; если в подходящем пресете карты (тот же ICCID) есть ключ ADM, рядом с ошибкой появляется кнопка **«Проверить ADM»**, а значок в заголовке (`ADM ✓/✗ ⚿`) становится кликабельным. Каждый неверный ключ расходует попытку (остаток показывается, повторная попытка требует подтверждения); заблокированный ADM требует ключа разблокировки карты
|
||||
|
||||
### Пользовательские файлы
|
||||
### Подсказки команд
|
||||
|
||||
Файлы, отсутствующие в модели pysim, можно добавить вручную:
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
|
||||
1. Перейдите на вкладку **Custom files**
|
||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||
4. Кликните для проверки существования — при успехе работает как обычный файл
|
||||
---
|
||||
|
||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||
## Профайлер
|
||||
|
||||
### Proactive UICC
|
||||
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
||||
|
||||
Подраздел **Proactive UICC** во вкладке Card Reader обеспечивает взаимодействие с CAT-сессией в реальном времени:
|
||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Клонировать** (копия профиля под именем *Copy of <имя>* открывается в редакторе), **Экспорт** (скачать JSON) и **Удалить**.
|
||||
|
||||
Правило файловой системы задаётся:
|
||||
|
||||
- **Путь** — от `MF` (напр. `MF/7F10/6F3A`) или от AID ADF (напр. `A0000000871002/6F07`).
|
||||
- **Проверка FCP/FCI** — **Только тип файла (FCP)**, **Тип файла + размер (FCP)** (добавляет размер файла или длину/число записей) либо **Полный FCI** (побайтовое сравнение сырого шаблона FCP `'62'` из ответа SELECT — ловит изменения FID/AID, life-cycle, security attributes и проприетарных параметров).
|
||||
- **Правила доступа** (необязательно) — проверяет условия доступа файла как декодированные правила: ссылка `8B` в FCI разрешается по копиям EF.ARR карты (или снимка), а декодированные строки сравниваются как множество — независимо от номера записи, поэтому карты с другой раскладкой EF.ARR всё равно проверяются (отсутствующие/лишние строки перечисляются; потерянная ссылка — ошибка; неразрешимая — непроверяемо). Заполняется автоматически при создании профиля с карты или из снимка; редактор показывает каждое правило структурированной строкой (биты операции / RESIZE FILE / INCREASE плюс селекторы условий безопасности, с предпросмотром канонической строки; `+ правило` добавляет, шаблонные формы остаются только для чтения), а блок сворачивается, «Нет» скрывает его. При **Полном FCI** флажок *«Игнорировать ссылку на EF.ARR в точном FCI»* сравнивает FCI без значения `8B` — другой номер записи больше не приводит к несовпадению, а смысл ссылки проверяют правила.
|
||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||
|
||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓, правила доступа ✗*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке), показывает расхождения содержимого файлов с декодером декодированным сравнением по полям (только отличающиеся поля, ожидаемое и фактическое рядом; сырая пара остаётся, когда декодированные значения совпадают) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. Расхождения правил доступа выводят декодированные строки правил (✓ совпадает, − отсутствует, + лишнее). В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||
|
||||
#### Опции сканирования «Профиль с карты»
|
||||
|
||||
Диалог сканирования запрашивает имя профиля и предлагает режим FCP/FCI, список **«Игнорировать содержимое файлов»** (все включены, кроме `EF.ARR`; чекбокс в заголовке переключает весь список) — `EF.LOCI`, `EF.PSLOCI`, `EF.EPSLOCI`, `EF.5GS3GPPLOCI`, `EF.Keys`, `EF.KeysPS`, `EF.SMS`, `EF.Kc`, `EF.KcGPRS`, `EF.LOCIGPRS`, `EF.CBMID`, `EF.SMSS`, `EF.ACC`, `EF.EPSNSC`, `EF.START-HFN`, `EF.ARR` — и две включённые по умолчанию маски, сохраняющие только первые 4 байта `EF.IMSI` и `EF.ICCID`. Строка прогресса показывает *N / всего файлов* с текущим путём; во время сканирования опции заблокированы. Правила создаются только для существующих файлов; пользовательские файлы из подвкладки **Custom files** проверяются на существование так же.
|
||||
|
||||
#### Снимки карт
|
||||
|
||||
Представление списка имеет три вкладки — **«Профили»**, **«Снимки карт»** и **«Пользовательские файлы»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. Захваченное содержимое показывается вместе с декодированным представлением, где есть декодер — таблица полей для transparent-файлов и однострочная сводка по каждой записи для record-файлов. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении.
|
||||
|
||||
- **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON.
|
||||
- В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**.
|
||||
- **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки.
|
||||
- **Сравнить снимки** сравнивает два снимка offline как проверка профиля, но побайтово для FCI и содержимого EF.ARR (смысловая проверка правил доступа относится к профилям): выберите *эталонный* снимок и *снимок для проверки* и получите тот же отчёт — сравнение снимков всегда точное (без масок содержимого). Файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
||||
#### Пользовательские файлы
|
||||
|
||||
Файлы, отсутствующие в модели pysim, можно добавить вручную на вкладке **Custom files** списка профайлера:
|
||||
|
||||
1. Выберите **Root (MF / ADF)** и введите путь **Parent DF** — сам корень, стандартный DF из дерева файлового менеджера или пользовательский DF (любая глубина; подсказки при вводе). Родитель, ещё не встреченный в дереве, остаётся допустимым и помечается `⚠`
|
||||
2. Введите 4-hex **FID** и псевдоним (`EF.…`/`DF.…`; префикс определяет, EF это или DF)
|
||||
3. Нажмите **Add** — файл появится в дереве файлового менеджера; кнопка **Edit** загружает запись в форму (кнопка становится **Save**, **Cancel** отменяет), **Delete** удаляет запись (удаление DF удаляет и дочерние записи после подтверждения)
|
||||
|
||||
Канонический путь убирает прежнюю неоднозначность, когда один и тот же файл можно было описать и относительно, и абсолютно. Пользовательские файлы сохраняются в `localStorage` между сессиями и включаются в сканирование карты с той же проверкой существования. Экспорт/импорт в JSON для обмена; устаревшие относительные пути (напр. `a153/4954`) разрешаются при загрузке, неразрешимые отбрасываются и показываются в списке.
|
||||
|
||||
## Симулятор
|
||||
|
||||
Вкладка **«Симулятор»** обеспечивает взаимодействие с CAT-сессией в реальном времени. Пять подвкладок: **«Телефон»** (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), **«Конфигурация TR»** (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), **«eSIM LPA»** (локальные операции с eUICC), **«BIP»** (терминальная сторона BIP поверх TCP) и **«Тест-скрипт»** (сценарные тест-скрипты действий с картой и ожиданий проактивных команд, хранятся в хранилище на сервере).
|
||||
|
||||
**Subscribed Events** — список событий SET UP EVENT LIST с кнопками **Send**. Клик открывает форму для конкретного типа события:
|
||||
|
||||
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
|
||||
- **Location Status** — выпадающий список: Normal / Limited / No service
|
||||
- **Access Technology Change** — 13 типов RAT
|
||||
- **Card Reader Status, Language, UICC Access** — соответствующие поля ввода
|
||||
- **Channel Status** — выбор канала, состояние линии (не установлена / TCP
|
||||
LISTEN / установлена) и информация (нет данных / линия разорвана), TS 102 223 8.56
|
||||
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
|
||||
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
|
||||
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор; при раскрытии видны декодированная команда и TERMINAL RESPONSE. Поддерживаются SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, PROVIDE LOCAL INFORMATION, TIMER MANAGEMENT, REFRESH и BIP-команды (OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA, GET CHANNEL STATUS); BIP-команды декодируются с обычными и comprehension-required TLV-тегами.
|
||||
|
||||
**PLI Data Dictionary** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
**Управление таймерами** — сервер выполняет роль терминала для TIMER MANAGEMENT (TS 102 223 §6.6.21/§7.4): запущенные картой таймеры отслеживаются в рамках сессии, TERMINAL RESPONSE на deactivate/get содержит остаток, а по истечении карта получает ENVELOPE (TIMER EXPIRATION). Живая карта использует это для повторения OTA-сессии после неудачного OPEN CHANNEL.
|
||||
|
||||
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
|
||||
|
||||
| Код | Декодированные поля |
|
||||
|------|--------------|
|
||||
@@ -483,15 +659,45 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
||||
|
||||
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
|
||||
|
||||
### Подсказки команд
|
||||
**Симуляция сети** — воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс: `projects/UICC_NAA.md`): **Холодная загрузка**, **Подключение EPS**, **Подключение 2G**, **Потеря сервиса**, **Ограниченный сервис**, **Роуминг запрещён**, **Серия переподключений**, **Принято SMS**, **Перенастройка CB** и **AUTHENTICATE**. Каждый сценарий отправляет событие Location status (только если карта на него подписана), обновляет контекст EPS NAS, location-файлы, Kc и файлы CB/SMS в точности как в трассах и журналирует каждый шаг с его SW. Параметры (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера, случайный роуминг-оператор и кнопка **«Домашняя сеть»**, заполняющая HPLMN карты из первой записи EF.HPLMNwAcT с откатом на IMSI), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели и число циклов/задержку. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; EF.FPLMN дописывается только сценарием **«Роуминг запрещён»** (TS 31.102 §4.2.16, без дубликатов), а подключение к сети из списка сначала очищает её запись (успешный ручной выбор, TS 23.122); 5GS location-файлы не записываются. Список операторов входит в поставку (`pysim_simple_server/data/mcc-mnc-list.json`, MIT; записи MVNO скрыты — в списке только реальные сети), переопределяется `--mcc-mnc-list`.
|
||||
|
||||
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
|
||||
**Монитор сетевого состояния** — компактная панель **«Сетевое состояние»** рядом с кнопками симуляции показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — **сэмулированное состояние сервиса** (*Не определено*, пока его не задаст сценарий или событие Location status, затем *Обычный сервис* / *Ограниченный сервис* / *Нет сервиса*) с пометкой *PLMN не разрешён*, если location-файлы или EF.FPLMN указывают на отказ, плюс текущее местоположение: PLMN, страна и оператор, LAI/RAI/TAI и **класс роуминга** (*Домашняя сеть*, если PLMN совпадает с HPLMN; *Эквивалентная домашней*, если он есть в EF.EHPLMN; иначе *Гостевая (роуминг)*). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (`init`, `write`, `read`, `refresh`); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку `… +N`). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status и никогда не опрашивает карту — кнопка **«Обновить»** перечитывает все файлы по требованию.
|
||||
|
||||
---
|
||||
**eSIM LPA** — для eUICC (SGP.22/SGP.32) подвкладка **«eSIM LPA»** читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+): **Chip** (EID, EUICCInfo1/2 с декодированными битовыми списками возможностей, ресурсами карты, идентификаторами CI PKI, категорией, запрещёнными PPR, данными сертификации; настроенные адреса SM-DP+ по умолчанию / корневого DS; таблица авторизации правил из ES10b GetRat), **Profiles** (состояние, nickname, провайдер, ICCID, AID ISD-P, класс, владелец и значок профиля в натуральную величину с типом/размером в байтах; **Enable**/**Disable** переключает профиль — карта обычно сначала присылает REFRESH, после чего сессия карты переинициализируется как при equip, и ICCID, сетевое состояние и все кэшированные представления перечитываются, а запрошенное состояние проверяется по перечитанному списку профилей) и **Notifications** (список ожидающих уведомлений, только чтение). Никаких загрузок профилей, обработки уведомлений и взаимодействия с SM-DP+ — используются только локальные функции ES10a/b/c; для карты не-eUICC подвкладка сообщает об этом.
|
||||
|
||||
## SCP81
|
||||
|
||||
Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) и содержит две подвкладки: **Listener** и **Scripts**.
|
||||
|
||||
**Listener** запускает и останавливает цель, на которую перенаправляется BIP-канал карты, в одном из четырёх режимов:
|
||||
|
||||
- **PSK TLS server** (по умолчанию) — PSK TLS-слушатель на **Host:Port**, отвечающий PSK-наборами TLS 1.2 из спецификации и ведущий HTTP-диалог административной сессии GP (заголовки `X-Admin-*`, `200` со строкой команд или `204 No Content`). PSK-ключи берутся из пресетов карт (вкладка **Cards**): ключ выбирается по идентификатору, который карта присылает в рукопожатии; если ни у одного пресета нет пары, Start не запускается. Ключи не сохраняются и не записываются в журнал; незнакомый идентификатор помечается записью `tls-psk-unknown`.
|
||||
- **Перенаправление на внешний сервер** — без локального слушателя: каждый BIP-канал подключается к указанной цели (нужны Host и Port), которая завершает TLS и ведёт административный диалог; запрошенный картой адрес только журналируется.
|
||||
- **Проброс (адрес карты)** — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (`Other address` + порт transport level, только TCP-клиент, удалённое соединение; порт по умолчанию спецификациями не задан, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).
|
||||
- **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.
|
||||
|
||||
Блок **Настройки** (свёрнут по умолчанию, метка «изменены» при отличиях; применяются при **Start**, сохраняются в браузере) открывает HTTP-фрейминг (Chunked body / размер чанка, 0 = одна TLS-запись / заголовок Connection / компактные заголовки, допустимо по RFC 7230 §3.2 / Next-URI: флажок + шаблон, выкл - команда без ответа), фрейминг скрипта (неопределённая `AE 80 …` или определённая `AA` длина Command Scripting template, теги comprehension-required, необязательный `X-Admin-Targeted-Application` со своим флажком) и **Показывать события канала** (во всех режимах, TS 102 223 §7.5.11). Соединение удерживается между POST-запросами и аккуратно закрывается на 204. У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; журнал и строка состояния показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как `tls-handshake-failed`.
|
||||
|
||||
**Script** выбирает список команд, отдаваемый в сессии: **None** (оставить настроенный на сервере скрипт) или один из скриптов, созданных на подвкладке **Scripts**; **Restart script** заново ставит выбранный скрипт в очередь с `force`, начиная с первого APDU.
|
||||
|
||||
Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out); панель **Script results (R-APDUs)** перечисляет каждый отданный C-APDU с его R-APDU и SW (прогресс `done/total`); журнал **HTTP OTA log** фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (`tls-handshake`, `tls-request`, `script-send`, `script-rapdu`, `script-page`, `script-done`, `data-available`, `peer-close`). Те же функции доступны через `POST /api/scp81/bip` и `GET /api/scp81/script` (см. `docs/api.md`).
|
||||
|
||||
### Скрипты
|
||||
|
||||
Подвкладка **Scripts** управляет именованными списками APDU, которые хранятся в `localStorage` (`simple_scripts`) и передаются серверу при старте слушателя. Каждый список — последовательность hex C-APDU, по одной в строке (допускаются комментарии `#`/`;`). Кнопка **New** создаёт список из шаблона:
|
||||
|
||||
| Шаблон | Что строит |
|
||||
|---|---|
|
||||
| **Empty** | пустой список |
|
||||
| **Explore ISD** | эталонная административная последовательность (`GET DATA FF21`, листинги GET STATUS ISD/ELF/приложений, `GET DATA 0085`); длинные листинги автоматически продолжаются страницами `SW 6310/CAFE` |
|
||||
| **Install from .cap** | INSTALL [for load] → LOAD ×N → INSTALL [for install] из `.cap` (опционально SD AID, параметры install/STK, make selectable) через `POST /api/scp81/gen-install`; файл используется только для генерации APDU — он не сохраняется, как и его имя |
|
||||
| **Delete AID** | APDU DELETE из списка AID (по одному в строке) с P2 = object only / object and related objects |
|
||||
|
||||
Таблица показывает имя, тип, число APDU и время создания каждого скрипта; **Edit** открывает редактор имени и списка APDU, **Delete** удаляет скрипт. В ходе сессии сервер отдаёт по одному C-APDU на каждый POST карты и отслеживает выполнение: карта сообщает статус в следующем POST (`X-Admin-Script-Status`), оборвавшаяся сессия досылает только невыполненные APDU (`X-Admin-Resume` продолжает, новый диалог начинает заново), а завершённый скрипт закрывается ответом `204 No Content`. Конструктор RAM/GP вкладки Remote APDU может отправить цепочку команд прямо в прогон кнопкой **«В очередь SCP81»**.
|
||||
|
||||
## PWA
|
||||
|
||||
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
|
||||
SIMple — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
|
||||
|
||||
- Service worker кеширует все ресурсы при первом посещении
|
||||
- Иконки 192×192 и 512×512
|
||||
@@ -502,49 +708,54 @@ OTAMan — Progressive Web App. Можно установить для offline-
|
||||
|
||||
## Локализация
|
||||
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`.
|
||||
Интерфейс на английском с поддержкой русского языка. Язык определяется из `navigator.language`. Кнопка переключения (EN/RU) в заголовке сохраняет выбор в `localStorage`. При переключении языка динамические представления (списки профилей, отчёты проверок, снимки, карты, proactive) перерисовываются.
|
||||
|
||||
## Совместимость версий
|
||||
|
||||
| PWA (OTAMan) | Сервер | Статус |
|
||||
| PWA (SIMple) | Сервер | Статус |
|
||||
|---|---|---|
|
||||
| 1.x.x | 1.x.x | ✅ Совместимы |
|
||||
| 1.x.x | 0.x.x | ❌ Сервер устарел |
|
||||
| 1.x.x | 2.x.x+ | ⚠️ Сервер новее — обновите PWA |
|
||||
| любая | та же мажорная | ✅ Совместимы |
|
||||
| любая | старее мажорная | ❌ Сервер устарел — обновите сервер |
|
||||
| любая | новее мажорная | ⚠️ Сервер новее — обновите PWA |
|
||||
|
||||
PWA проверяет версию сервера при подключении через `GET /api/version`.
|
||||
PWA проверяет версию сервера при подключении через `GET /api/version` и сравнивает мажорную версию (например, PWA 3.x с сервером 3.x; сервер 2.x помечается как устаревший).
|
||||
|
||||
---
|
||||
|
||||
## Сервер (pysim-otaman-server)
|
||||
## Сервер (pysim-simple-server)
|
||||
|
||||
Встроенный Python-сервер оборачивает [pySim](https://osmocom.org/projects/pysim/wiki) и раздаёт как PWA (из `frontend/`), так и JSON API по `/api/*`.
|
||||
|
||||
### Требования
|
||||
|
||||
- **Python 3.8+** с `pip`, и **Git**
|
||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux)
|
||||
- **Windows** — используйте Python 3.10–3.13 (рекомендуется 3.13): у `pyscard` есть готовые wheel. На 3.9 / 3.14 он собирается из исходников (нужны MSVC C++ Build Tools). SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
||||
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
||||
- **Смарт-картридер** (PC/SC или serial/FTDI) — предпочтителен PC/SC (`pcsc-lite` + `ccid` на Linux). У `pyscard` нет Linux-wheel, поэтому он собирается из исходников: поставьте и пакет с заголовками PC/SC (`libpcsclite-dev` в Debian/Ubuntu, `pcsc-lite-devel` в Fedora; в Arch они входят в `pcsclite`).
|
||||
- **Windows** — используйте **Python 3.13 (настоятельно рекомендуется)**: у `pyscard` есть готовые wheel до 3.13, и SCP81 TLS-PSK-слушателю нужен 3.13+. На 3.14 wheel для `pyscard` и части зависимостей pysim отсутствуют, они собираются из исходников (нужны MSVC C++ Build Tools). 3.9–3.12 запускают приложение, но не SCP81 PSK-слушатель. SMPP-мост (`smpp.twisted3`) в Windows сознательно не ставится.
|
||||
|
||||
### Скрипты
|
||||
|
||||
| Скрипт | Назначение |
|
||||
|--------|-------------|
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования. |
|
||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||
|
||||
`start.sh` автоопределяет ридер (PC/SC при работающем `pcscd`, иначе `/dev/ttyUSB0`); `start.bat` всегда использует `-p 0`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
||||
`start.sh` при работающем `pcscd` запрашивает первый PC/SC-ридер; иначе сервер сам автоопределяет PC/SC-ридер, а если его нет — pysim откатывается на последовательное устройство по умолчанию `/dev/ttyUSB0` (для serial/FTDI-ридера укажите `-d <устройство>`). `start.bat` всегда передаёт `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**.
|
||||
|
||||
### Ручная установка
|
||||
### Ручная установка (Linux/macOS)
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS (Windows: .venv\Scripts\activate)
|
||||
source .venv/bin/activate
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
pip install -e . # editable — раздаёт frontend/ из исходного дерева
|
||||
pysim-otaman-server --http-port 8080
|
||||
pysim-simple-server --http-port 8080
|
||||
```
|
||||
|
||||
В Windows используйте `setup.bat`: он ставит зависимости pysim из
|
||||
`requirements-pysim.txt`, а сам pysim — с `--no-deps`, поэтому SMPP-мост (и его сборка
|
||||
через MSVC) не подтягивается.
|
||||
|
||||
### Параметры CLI
|
||||
|
||||
| Параметр | Описание |
|
||||
@@ -556,14 +767,37 @@ pysim-otaman-server --http-port 8080
|
||||
| `-d` / `--device` | Путь к serial-устройству |
|
||||
| `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) |
|
||||
| `--apdu-trace` | Лог APDU-трафика в stderr |
|
||||
| `--gsmtap [HOST[:PORT]]` | Поток APDU карты в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Только APDU (без ATR), в «проводной» форме (у case-4 убирается Le, данные — как GET RESPONSE TPDU, ответы case-2/3 объединяются с командой). Совместимо с `--apdu-trace` |
|
||||
| `--log-requests` | Лог запросов/ответов в stderr |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с) |
|
||||
| `--sms-oa` / `--sms-sm-sc` | Адрес отправителя SMS-DELIVER / SM-SC для PoR-in-submit |
|
||||
| `--terminal-profile` | Hex TERMINAL PROFILE (по умолчанию — 33-байтовый профиль реального телефона с BIP-событиями/командами; без него живая карта не запускает HTTP OTA) |
|
||||
| `--poll-interval` | Интервал автоопроса STATUS (по умолчанию 30с; `0` отключает опрос) |
|
||||
| `--mcc-mnc-list` | Переопределить встроенный мировой список MCC/MNC (`pysim_simple_server/data/mcc-mnc-list.json`) |
|
||||
| `--full-pysim-init` | Штатная инициализация/equip из pysim (с лишними сбросами карты). По умолчанию инициализация без лишних сбросов — карта переподключается только по явным equip/reset |
|
||||
| `--no-auto-equip` | Не инициализировать карту автоматически сразу после вставки (по умолчанию автоинициализация включена) |
|
||||
| `--menu-timeout` | Автоответ timeout TERMINAL RESPONSE на приостановленную STK-команду (по умолчанию 60с; `0` отключает) |
|
||||
| `--timing` | Лог длительности фаз, сбросов карты и счётчиков APDU с отметками времени |
|
||||
|
||||
### Устранение неполадок
|
||||
|
||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
||||
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — обычная смена карты переподключается на существующем транспорте (карточные ошибки PC/SC больше не вызывают пересоздание транспорта), неудавшийся авто-equip повторяется до 3 раз, а сторож перезапускает его каждые 5 с, пока карта в ридере. Снятие карты теперь полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||
|
||||
### Справочник API
|
||||
|
||||
Полный справочник endpoints: [docs/api.md](docs/api.md).
|
||||
|
||||
## Лицензия
|
||||
|
||||
**GPL-2.0-or-later** — см. [LICENSE](LICENSE). Это та же лицензия, что у
|
||||
[pySim](https://github.com/osmocom/pysim), библиотеку которого импортирует
|
||||
сервер, и у [simtrace-analyser](https://github.com/anttro/simtrace-analyser),
|
||||
откуда портированы некоторые клиентские декодеры.
|
||||
|
||||
Сам pySim не входит ни в этот репозиторий, ни в релизные артефакты: `setup.sh` /
|
||||
`setup.bat` устанавливают его из его upstream git-репозитория.
|
||||
|
||||
Встроенные сторонние компоненты (список операторов MCC/MNC и AES/DES-бандлы в
|
||||
PWA) сохраняют свои лицензии MIT — см. [THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
SIMple / pysim-simple-server — third-party notices
|
||||
===============================================================================
|
||||
|
||||
This project is licensed under **GPL-2.0-or-later** (see LICENSE), the same
|
||||
license as the pySim library it imports and as simtrace-analyser, from which
|
||||
some client-side decoders were ported.
|
||||
|
||||
Runtime dependency (not included)
|
||||
---------------------------------
|
||||
pySim — https://github.com/osmocom/pysim — GPL-2.0-or-later.
|
||||
|
||||
pySim is NOT included in this repository or in the release artifacts:
|
||||
setup.sh / setup.bat install it from its upstream git repository, so its
|
||||
source and license are obtained from there.
|
||||
|
||||
Bundled third-party components
|
||||
------------------------------
|
||||
1. MCC/MNC operator list — pysim_simple_server/data/mcc-mnc-list.json
|
||||
Source: https://github.com/pbakondy/mcc-mnc-list
|
||||
License: MIT (Copyright (c) 2016 Peter Bakondy).
|
||||
The full notice, the upstream commit and the retrieval date are in
|
||||
pysim_simple_server/data/mcc-mnc-list.LICENSE.
|
||||
|
||||
2. AES implementation — frontend/aes-bundle.js
|
||||
Package: aes-js 3.1.2 (https://github.com/ricmoo/aes-js)
|
||||
License: MIT
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Richard Moore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
3. DES implementation — frontend/des-bundle.js
|
||||
Package: des.js 1.1.0 (https://github.com/indutny/des.js)
|
||||
License: MIT
|
||||
|
||||
This software is licensed under the MIT License.
|
||||
|
||||
Copyright Fedor Indutny, 2015.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+1747
-88
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
# SCP81 / HTTP OTA live-card findings
|
||||
|
||||
Living debug log for the HTTP OTA (RAM over HTTP) work against the live UICC.
|
||||
Purpose: record **every attempted configuration and its outcome**, so the same
|
||||
variations are not repeated. Add rows as tests are run; keep the confirmed
|
||||
rules section current.
|
||||
|
||||
Setup: `pysim_simple_server` with a PC/SC reader, the PWA SCP81 tab (or
|
||||
`POST /api/scp81/bip`), the card triggered by its SMS-PP push / the Location
|
||||
status event. Server log at `GET /api/scp81/log`, script state at
|
||||
`GET /api/scp81/script`, proactive history at `GET /api/proactive-log`.
|
||||
|
||||
## RESOLVED 2026-09-16: the card never received the response - BIP TLV bug
|
||||
|
||||
**Root cause:** our RECEIVE DATA TERMINAL RESPONSE encoded the channel-data
|
||||
TLV length as a raw byte (`36 ED ...` for a 237-byte chunk). BER requires the
|
||||
long form for lengths >127: **`36 81 ED ...`** (the reference terminal traces
|
||||
use exactly that, e.g. `push_3311_success_req2.pcapng`). The card's BIP layer
|
||||
silently mis-parsed the malformed TLV, so the TLS record bytes never reached
|
||||
its TLS stack: no alert, no script processing, and the SD kept resuming its
|
||||
dialog ("no complete script received") forever. Every delivery <=127 bytes
|
||||
(handshake records, 204 responses) always worked - which is why the handshake
|
||||
succeeded and only the large script responses "vanished".
|
||||
|
||||
**Fix:** `_handle_bip_command` (cmd 0x42) BER-encodes the channel data length
|
||||
(`36 81 <len>` above 127); regression test
|
||||
`test_receive_data_tlv_long_form_length`.
|
||||
|
||||
**Result with the live card** (one push, `explore` script, 5/5 commands):
|
||||
|
||||
```
|
||||
#1 80CAFF2100 SW 9000 FF210B 81010D 8202C5D6 83020962 (13 applets,
|
||||
free NV 50646 B, free volatile 2402 B)
|
||||
#2 80F28002024F0000 SW 9000 ISD A000000003000000 + D276000005AAFFCAFE00
|
||||
#3 80CA008500 SW 9000 stored HTTP OTA parameters
|
||||
#4 80F24002024F0000 SW CAFE 127-byte ELF registry page (more available)
|
||||
#5 80F21002024F0000 SW CAFE 127-byte applications page (more available)
|
||||
```
|
||||
|
||||
Every command returned `X-Admin-Script-Status: ok` on the card's own POST to
|
||||
the incremented `X-Admin-Next-URI`, on the same keep-alive connection, and the
|
||||
session ended with 204 + mutual close_notify - exactly the reference flow.
|
||||
`SW CAFE` marks a truncated 127-byte page: the remaining entries need a
|
||||
continuation GET STATUS (P2=02 with the last AID as search criterion).
|
||||
|
||||
## Live card facts (verified via the reader, 2026-09-16)
|
||||
|
||||
- `80CAFF2100` (GET DATA extended card resources) **works**:
|
||||
`FF21 0B 81 01 0D 82 02 C5 D6 83 02 09 62` -> 13 applets installed,
|
||||
free NV memory `0xC5D6` = 50646 B, free volatile `0x0962` = 2402 B.
|
||||
- `80CA008500` (GET DATA HTTP administration parameters) **works** and returns
|
||||
the SD's stored OTA configuration: `8A 09 "localhost"`, `8B 14 <agent id>`,
|
||||
`8C 01 "/"` (stored URI), `85 14 <PSK identity>`, `86 07 00 01 25 03 00 10 00`
|
||||
(retry counter 1, timer **1 minute** — the `25 03` h:m:s fields are TP-SCTS
|
||||
semi-octets, TS 23.040 §9.1.2.3: low nibble = most significant digit, so
|
||||
`10` = `01` min), `02 40 01` (KVN/KID), APN-ish
|
||||
`C7 04 03 47 50 42`, destination `BE 05 21 5B D5 05 02` = 91.213.5.2.
|
||||
- `80F28002/80F24002/80F21002 ...4F0000` return `6985` through the reader when
|
||||
the ISD is not the current DF; the reference platform sends
|
||||
`80F28002024F0000` over HTTP, where the SD executes inside the ISD.
|
||||
- `SELECT` of the ISD (`00A4040008A000000003000000`) returns `6112`;
|
||||
a subsequent GET RESPONSE (`00C0000012`) returns `6D00`.
|
||||
- BIP device identities: OPEN CHANNEL uses destination `0x82`; SEND/RECEIVE
|
||||
DATA carry channel `0x21..0x27` (e.g. `82 02 81 22` = channel 2).
|
||||
- Subscribed events (`99 03`): `03` location status, `09` data available,
|
||||
`0A` channel status.
|
||||
- A Location status event re-triggers the OTA session only while the last
|
||||
session is incomplete; after a clean session end the card waits for a push.
|
||||
- The SD stores a 1-minute retry timer (`25 03 00 10 00`; timer fields are
|
||||
TP-SCTS semi-octets per TS 102 223 §8.38 / TS 23.040 §9.1.2.3).
|
||||
|
||||
## Confirmed rules (with evidence)
|
||||
|
||||
1. **The card needs a clean TLS close, with the close_notify actually
|
||||
fetched.** Keep-alive (no close) -> fatal `unexpected_message` after it
|
||||
fetched the response. `close_notify` sent *after* the buffer drained is
|
||||
never fetched (the card ends the dialog on its own first). Correct order:
|
||||
send it while the response still waits, then wait for the drain, then
|
||||
close.
|
||||
2. **The card's abort alert is `fatal unexpected_message`** - decrypted with
|
||||
the listener's `keylog` option (see `tools/scp81_decrypt.py`).
|
||||
3. **A dropped link must be signalled (TS 102 223 7.5.11), and only after the
|
||||
buffered data was fetched.** Signalling the drop while bytes are still in
|
||||
the BIP buffer makes the card abort the fetch mid-record and end the
|
||||
session. Omitting the signal entirely hangs the SD: after a listener
|
||||
restart dropped the channel silently, the card ignored pushes and location
|
||||
events for minutes; a manual `ENVELOPE (Channel status, B8 02 02 05)`
|
||||
immediately made it start a fresh session.
|
||||
4. **The Next-URI shape matters.** A path-only or absolute Next-URI (`/`,
|
||||
`/1`, `http://127.0.0.1:8443/api/scp81`) draws the fatal
|
||||
`unexpected_message`; the reference-style relative path **with a query**
|
||||
(`/adminserver?PHPSESSID=...&apdu_id=101`) does not.
|
||||
5. **The reference administration server** (`samples/HTTP_OTA/
|
||||
httpota_adminserver_php_v2`) uses: command script
|
||||
`AE 80 22 <len> <apdu> 00 00`; response `200` with
|
||||
`X-Admin-Protocol`, `X-Admin-Next-URI: /adminserver?PHPSESSID=<id>&apdu_id=<n>`,
|
||||
`Content-Type: ...;version=1.0`, **chunked** body (100-byte chunks);
|
||||
the card returns the R-APDU as the body of its next POST with
|
||||
`X-Admin-Script-Status: ok`; the server ends with `204`.
|
||||
Its log proves the card followed the Next-URI three times within 1-2 s per
|
||||
step (`Got next request ... Script status is 'ok' - storing R-APDU data`).
|
||||
6. **`chunked=false` (Content-Length) has never produced an R-APDU.** All
|
||||
sessions that ended silently (clean close, no alert, no POST) used
|
||||
`Content-Length`. Hypothesis: the card only treats a chunked body as a
|
||||
command script; with Content-Length it sees an empty script, executes
|
||||
nothing and ends the session gracefully.
|
||||
|
||||
## The one fully successful session trace (ground truth)
|
||||
|
||||
`traces/HTTPOTA_session_3311_success1.pcap` (2019, **plain HTTP on port 80**,
|
||||
one TCP connection for the whole session, card `3311` - *not* our UICC):
|
||||
|
||||
```
|
||||
POST /server/adminagent?cmd=1 <- card (trigger URI, with query!)
|
||||
200 OK + Date/Server + X-Admin-Protocol
|
||||
+ X-Admin-Next-URI: /Download?req=1 + Content-Length: 11
|
||||
+ Content-Type: .../card-content-mgt;version=1.0
|
||||
body: ae 80 22 05 80 ca 00 85 00 00 00 (script: GET DATA 0085)
|
||||
POST /Download?req=1 <- card, SAME connection
|
||||
X-Admin-Script-Status: ok
|
||||
Content-Type: .../card-content-mgt-response;version=1.0
|
||||
Transfer-Encoding: chunked
|
||||
body: "8
|
||||
" af 80 23 02 6a 88 00 00 "0
|
||||
|
||||
" (R-APDU SW 6A88)
|
||||
200 OK + X-Admin-Next-URI: /Download?req=2 + Content-Length: 14
|
||||
body: ae 80 22 08 80 f2 80 02 02 4f 00 00 00 00 (GET STATUS P1=80)
|
||||
POST /Download?req=2 -> X-Admin-Script-Status: ok, chunked
|
||||
body: "1F
|
||||
" af 80 23 19 <25-byte R-APDU ... 90 00> 00 00 "0
|
||||
|
||||
"
|
||||
200 OK + /Download?req=3 + 11-byte script
|
||||
POST /Download?req=3 -> status ok, R-APDU 23 02 6d 00 (SW 6D00)
|
||||
204 No Content <- session ends
|
||||
```
|
||||
|
||||
Confirmed from it: the card echoes the `X-Admin-Next-URI` (path *and* query)
|
||||
verbatim; its response POST goes on the **same TCP connection**; its response
|
||||
is the `AF 80 23 <len> <R-APDU> 00 00` indefinite Response Scripting template
|
||||
(in a chunked body, with `X-Admin-Script-Status`); the server's script
|
||||
`AE 80 22 <len> <APDU> 00 00` matches ours byte for byte; the server uses
|
||||
`Content-Length` (not chunked), no `Connection` header (implicit keep-alive),
|
||||
and ends with 204.
|
||||
|
||||
## Attempt matrix
|
||||
|
||||
| # | transport | framing | Next-URI | close | link events | outcome |
|
||||
|---|-----------|---------|----------|-------|-------------|---------|
|
||||
| 1 | dump mode only | - | - | - | off | OPEN CHANNEL + ClientHello captured (Phase A) |
|
||||
| 2 | TLS, 204 only | - | - | yes | off | session completes cleanly, no alert (Phase B, live) |
|
||||
| 3 | TLS + script | chunked 100 | `/N` | early (raced fetch) | on | fetch truncated (237/399); card re-opened and repeated its POST with `X-Admin-Resume: true` -> breakdown-resume works |
|
||||
| 4 | TLS + script | chunked 100 / single | `/1`, `/`, absolute | keep-alive | off | full fetch, then fatal `unexpected_message` (Next-URI shape) |
|
||||
| 5 | TLS + script | single | none (`""`) | keep-alive | off | no alert, no POST, session left open (spec: no Next-URI -> no response) |
|
||||
| 6 | TLS + script | chunked 100 | reference | close_notify after drain | off | full fetch, alert (notify never fetched) |
|
||||
| 7 | TLS + script | chunked 100 | reference | close_notify before drain | off | full fetch, alert (head split into its own record) |
|
||||
| 8 | TLS + script | **single record** | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`chunked=false` -> suspected empty script) |
|
||||
| 9 | TLS + script | single record | reference | keep-alive (no close) | off | fatal `unexpected_message` (close required) |
|
||||
| 10 | TLS + script | chunked 100 | reference | drain + close_notify | off | full fetch, then alert; later the SD hung until a manual link-dropped event |
|
||||
| 11 | TLS + script | single record | reference | keep-alive | off | fatal `unexpected_message` after the full fetch (no close) |
|
||||
| 12 | TLS + script | single record | reference | drain + close_notify | off | **no alert**, card CLOSE CHANNELs, no R-APDU (`Content-Length`) |
|
||||
| 13 | TLS + script | chunked100 + single | reference | drain + close_notify | off | no alert, no R-APDU |
|
||||
| 14 | TLS + script | single record | reference | keep-alive | off | alert again |
|
||||
| 15 | TLS + script | chunked 100 | reference | keep-alive | on | alert (small records, ruled out record size) |
|
||||
| 16 | TLS + script | single record | reference | keep-alive, no `Connection` header | on | alert |
|
||||
| 17 | TLS + script (RFM! `00D6` write-probe) | chunked, single | reference | drain + close_notify | on | no alert, no R-APDU; EF.SPN unchanged - **RFM result is void**: the ISD only accepts RAM commands |
|
||||
|
||||
All script attempts used the `explore` list, except #8-#17 which used only
|
||||
`80CAFF2100` (or the RFM probe). #3-#17 ran with the card's PSK identity
|
||||
`89390…903` (push trigger) or `89701…` (event trigger).
|
||||
|
||||
**Status after #17 (superseded by the 2026-09-16 resolution above):** the
|
||||
failures were caused by the BIP TLV length bug, not by the HTTP/TLS details;
|
||||
resume mode was a symptom (the working session even started as a resume). The
|
||||
key working recipe (also now the server default): one keep-alive connection,
|
||||
`Transfer-Encoding: chunked` body with the script in
|
||||
one TLS record, no Connection header, `X-Admin-Next-URI` with a query whose
|
||||
command id increments. (2.2.14: the reference server's Date/Server/X-Powered-By
|
||||
headers were dropped — they were mimicry, the blocker was the BIP length bug.)
|
||||
|
||||
**Also confirmed:** a TLS half-close (close_notify then keep reading for the
|
||||
card's POST which RFC 5246 leaves open in practice) cannot be done with
|
||||
CPython's `ssl`: `SSLSocket.unwrap()` with a short timeout raises and poisons
|
||||
the session (tested), so the `half_close` option is a documented no-op.
|
||||
|
||||
## RESOLVED 2026-09-16b: SW CAFE continuation pages
|
||||
|
||||
**Implemented:** the script responder auto-follows a truncated listing page
|
||||
(`SW CAFE`, 127 bytes) by inserting a continuation GET STATUS
|
||||
(`80F2 <P1> 02 <Lc> 4F <len> <last-complete-AID> 00`, next-occurrence mode)
|
||||
as the next command. The last AID comes from the last complete `E3` entry in
|
||||
the page (truncated tails and the live `FC`-prefixed junk are skipped).
|
||||
Logged as `script-page`; a repeated page logs `script-page-stalled` and
|
||||
stops; max 24 pages; inserted continuations are dropped at session start.
|
||||
|
||||
**Live-verified (2026-09-16):** ELF registry: page 1 `SW CAFE` ->
|
||||
continuation with `D276000005AA060200000000B00000` -> page 2 `SW 9000`
|
||||
(complete, 2 entries). Applications: page 1 `SW CAFE` -> continuation with
|
||||
`D276000005AAFFCAFE0010` -> page 2 `SW 9000` (complete, incl.
|
||||
`D276000005AAFFCAFE0001/0010`, `A0000001515350`, `A000000151535041`).
|
||||
Full session: 7/7 commands, all `X-Admin-Script-Status: ok`.
|
||||
|
||||
## RESOLVED 2026-09-16c: RAM install over SCP81 - BER length in the script template
|
||||
|
||||
**Root cause:** `_scp81_command_body` wrote the C-APDU TLV length as a raw
|
||||
byte (`AE 80 22 F5 <245 bytes> 00 00` for a 245-byte LOAD). BER reads a byte
|
||||
above 0x7F as a long-form marker, so the card mis-parsed every LOAD >128
|
||||
bytes; the small INSTALL commands (<128 bytes) executed normally, which made
|
||||
the install look alive. Symptoms: the card accepted the LOAD responses with
|
||||
`X-Admin-Script-Status: ok` but sent a degenerate `AF 80` body, no LOAD
|
||||
R-APDU appeared in the results, and the final INSTALL [for install] answered
|
||||
`6A88` (module not found) because the package was never loaded.
|
||||
|
||||
**Fix:** both the indefinite ("22" TLV) and definite ("AA" outer) template
|
||||
lengths are BER-encoded (`_ber_len_bytes`); the same rule as the BIP channel
|
||||
data TLV fix earlier the same day. Tests cover the 245-byte LOAD body, the
|
||||
short-form case and the definite variant.
|
||||
|
||||
## RESOLVED 2026-09-16d: RAM install - LOAD blocks were overlapping copies
|
||||
|
||||
**Root cause:** the LOAD block slicer indexed the load file TLV with the
|
||||
*block number* (`loadfile_tlv[i * 2:(i + 240) * 2] for i in range(blocks)`)
|
||||
instead of a *character offset*, so every block after the first was a
|
||||
1-byte-shifted copy of its predecessor. On the wire the cap header repeated
|
||||
every 239 bytes. The card accepted the first three blocks and failed block 4
|
||||
with `SW 6400` (execution error), then refused the rest (`6985`) and the
|
||||
final INSTALL answered `6A88`. The same slicing lived in the SCP80
|
||||
/api/ram-install path (the helper was extracted from it), so multi-block caps
|
||||
could never install there either.
|
||||
|
||||
**Fix:** consecutive chunks at char offsets
|
||||
(`range(0, len(tlv), 240 * 2)`), with a reassembly test that pins the joined
|
||||
blocks to the C4 TLV byte for byte.
|
||||
|
||||
## RESOLVED 2026-09-16e: RAM install over SCP81 - complete and verified
|
||||
|
||||
**Live-verified end-to-end**: .cap parse -> INSTALL [for load] (SW 9000) ->
|
||||
LOAD x6 (all SW 9000, after the block-slicing fix) -> INSTALL [for install]
|
||||
with the full parameter set -> SW 9000. The applet's INSTALL [for install]
|
||||
needed the real install parameters (`C900` + the STK parameters
|
||||
`EA 0C 80 0A ...`), which the compact SCP81 form could not express - the
|
||||
Remote APDU -> RAM -> INSTALL [for install] builder has all the fields and
|
||||
its "Queue in SCP81" button feeds the commands straight into the HTTP OTA
|
||||
script (the "To expanded" button shows them in the TS 102 226 command
|
||||
scripting (AA/AE80) form). The card's answer for the parameter-less attempt
|
||||
was SW 6A80 (incorrect parameters in data field).
|
||||
|
||||
Working INSTALL [for install] example (compact):
|
||||
`80E60C002E07AA1902BC22580108AA1902BC2258010108AA1902BC22580101010010C900EA0C800A00000F010000000000000000`
|
||||
|
||||
## RESOLVED 2026-09-16f: SW CAFE pagination used the wrong P2 (02 instead of 03)
|
||||
|
||||
**Root cause:** the continuation GET STATUS used `P2=02`, which Table 11-34
|
||||
(GP Card Spec 2.3.1) defines as "**Get first or all occurrence(s)**" - the
|
||||
card returned the first listing again (with the search criterion's single
|
||||
match), so every listing appeared to end after one extra page and newly
|
||||
installed/registered entries were invisible (the installed package
|
||||
`AA1902BC225801` was missing from the ELF registry). The correct value is
|
||||
`P2=03` = "**Get next occurrence(s)**".
|
||||
|
||||
**Fix:** the continuation repeats the *same* GET STATUS command with P2.b1
|
||||
set (`80F2 <P1> 03 <same data> 00`) - the pagination state lives in the card.
|
||||
A changed `4F` criterion is a match filter, not a position: `P2=03` combined
|
||||
with the last AID as criterion is rejected with SW 6A80, and `P2=02` with it
|
||||
returns that single match (the duplicate seen earlier). The card's
|
||||
truncation warning is its proprietary `CA FE`; GP defines `63 10` (Table
|
||||
11-38) and both trigger the continuation.
|
||||
|
||||
**Also fixed (same week):** the explore script's P1 values - per Table 11-33
|
||||
`P1=40` is *applications and supplementary security domains*, `P1=20` the
|
||||
*ELF registry* and `P1=10` *ELF+modules*; the script never queried the
|
||||
ELF-only registry, which is why the installed package `AA1902BC225801` was
|
||||
invisible. Labels/decoder updated; the remote APDU script builder's P1 map
|
||||
(0x02 load / 0x0C install / 0x08 make-selectable / 0x40 reg-update / 0x10
|
||||
extradition) was already correct.
|
||||
|
||||
## RESOLVED 2026-09-16g: response R-APDU TLV length also needs BER long form
|
||||
|
||||
**Root cause:** `_scp81_parse_response` read the `23` (R-APDU) TLV length as a
|
||||
raw byte. A listing page above 127 bytes arrives as `AF 80 23 81 FC <252
|
||||
bytes> 00 00`; the parser took `0x81` as the length, so every page was
|
||||
silently cut to 127 bytes with a bogus status word (the data's last two
|
||||
bytes, e.g. `CAFE`/`0001`/`9F70` instead of the real `63 10`). The bogus SW
|
||||
also stopped the pagination, so later registry entries - including the
|
||||
installed package `AA1902BC225801` - never appeared.
|
||||
|
||||
**Fix:** the response template TLVs use `httpota.ber_len_read` (BER length,
|
||||
same class of bug as the channel data TLV and the command script template
|
||||
earlier the same day). Regression tests cover a 250-byte page with
|
||||
`23 81 FC` and the short-form case.
|
||||
|
||||
## VERIFIED 2026-09-16h: the installed applet in all registries
|
||||
|
||||
After the response-TLV BER fix, `explore` ran 18 commands / 10 auto
|
||||
continuation pages (all real statuses: `63 10` -> next, `9000` = complete)
|
||||
and the installed applet shows up everywhere:
|
||||
|
||||
```
|
||||
80F240 (applications+SDs): AA1902BC22580101 life=07 (SELECTABLE) priv=00 elf=AA1902BC225801
|
||||
80F220 (ELF registry): AA1902BC225801 life=01 (loaded)
|
||||
80F210 (ELF+modules): AA1902BC225801 life=01 module=AA1902BC22580101
|
||||
```
|
||||
|
||||
Full RAM-over-HTTP install cycle: .cap -> INSTALL [for load] -> LOAD x6 ->
|
||||
INSTALL [for install] -> registries.
|
||||
|
||||
## Next tests / work
|
||||
|
||||
1. **UI:** group the per-page R-APDUs under their logical command in the
|
||||
SCP81 tab (page merging/decoding for ELF and application listings).
|
||||
2. **Load/store over SCP81:** implemented - `POST /api/scp81/gen-install`
|
||||
takes a `.cap`, expands it with the shared `_cap_apdu_sequence` helper
|
||||
(INSTALL [for load] -> 240-byte LOAD blocks -> INSTALL [for install]) and
|
||||
returns the APDU list, which the PWA stores as an "Install from .cap"
|
||||
script (the `.cap` itself is never stored). Live install verified
|
||||
2026-09-16.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tools/scp81_decrypt.py <log.json> <keys.log>` - decrypts the dialog from
|
||||
`GET /api/scp81/log` plus the listener's `keylog` file (SSLKEYLOGFILE
|
||||
format; PSK-AES128-CBC-SHA256, TLS 1.2 PRF + OpenSSL CLI). Shows each
|
||||
record's plaintext and any alert level/description.
|
||||
- Start the listener with `"keylog": "/tmp/.../scp81.keys"` to collect the
|
||||
secrets (contains key material - use a temp path, never commit).
|
||||
@@ -0,0 +1,767 @@
|
||||
# Test script authoring guide
|
||||
|
||||
A **test script** is a JSON document that drives a deterministic dialogue with
|
||||
the card over the local server: ordered **actions** (each sends something and
|
||||
checks the response) and **expectations** (each fetches the proactive command
|
||||
the card announced, checks it, and sends the scripted TERMINAL RESPONSE).
|
||||
|
||||
Scripts live in the **server-side store**
|
||||
(`~/.pysim-simple-server/test_scripts.json`, `--test-scripts`): the PWA's Test
|
||||
script pill manages them (Import/Export move JSON in and out, the old
|
||||
localStorage export imports as-is), and any API client can create or run them
|
||||
(`POST /api/test/run` with an inline `script` or a stored `script_id` - see
|
||||
`docs/api.md`). While a run is active the card belongs to the script.
|
||||
|
||||
**Test suites** (v3.22.0) group the scripts: a suite is the root object (every
|
||||
script belongs to exactly one suite, `suite_id` in the script store) and the
|
||||
runner executes its scripts in order - the optional **setup** first, the
|
||||
**member** scripts, the optional **teardown** last - in **one card session,
|
||||
without resets and without resume**. Per member `on_fail` decides whether a
|
||||
failure stops the suite (`stop`, default) or the next script runs
|
||||
(`continue`); the **teardown always runs** on a stop or a failure (its job is
|
||||
to leave the card ready for a new test) as long as the card is in the reader,
|
||||
and a card reset (session change) stops the suite and is flagged in the
|
||||
report. A suite (or a single script) may require the **ADM** verified: the
|
||||
matched preset's key is tried **once** at the start - a failure refuses the
|
||||
run and needs a manual verify (the ADM badge) before the next attempt. The
|
||||
suite run's report (the PWA's report view, exportable as JSON or Markdown)
|
||||
carries every member's step table, failing notes and run-log excerpt, plus the
|
||||
summary (pass/warn/fail/skipped, wall time, the SCP80 counters before/after
|
||||
per keyset and the card-session flag). The suites live in
|
||||
`~/.pysim-simple-server/test_suites.json` (`--test-suites`). A bundle
|
||||
import always merges: importing a suite export **into the store it came
|
||||
from duplicates the suite and its scripts** (an id already present gets a
|
||||
fresh uuid) - restore into a fresh store, or wipe the target first.
|
||||
|
||||
A suite may **start with a card reset** (`start_reset`, the suite editor's
|
||||
*Start with card reset*, v3.23.0): before anything runs, the server
|
||||
physically resets the card and performs the full re-initialization (ICCID,
|
||||
network state, TERMINAL PROFILE, the equip drain), so the scripts run against
|
||||
a freshly initialized card. The run's preset must still match the card's
|
||||
ICCID after the reset (a mismatch refuses the start - re-select the preset
|
||||
and retry), and a `require_adm` suite verifies the ADM again for the new card
|
||||
session. The initialization **consumes the card's initial proactive
|
||||
commands** (the menu, the event list, a PLI), so a script cannot catch the
|
||||
"first command after reset" event - those commands stay in the proactive log
|
||||
as evidence. A reset *during* the run is still fatal: the suite stops and
|
||||
the remaining members are skipped (flagged in the report).
|
||||
|
||||
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
|
||||
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
|
||||
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
|
||||
`envelope` action's `src` override since 3.18.1, the inline `por` object, the
|
||||
`alpha`/`sms` checks, `scp80.params.format` and the case-insensitive
|
||||
`menu-select` text match since 3.19.0, the `proactive-drain` action (the kind
|
||||
`proactive` until 3.22.1) and the
|
||||
ok-answering bounded cleanup since 3.21.0, the suites and the Simulator's
|
||||
event forms for all modelled events since 3.22.0, a suite's `start_reset`
|
||||
since 3.23.0.
|
||||
|
||||
## Writing a test for an applet
|
||||
|
||||
Collect these values before writing the JSON (from the applet's source/spec
|
||||
and the card's preset):
|
||||
|
||||
| What | Where it goes | Notes |
|
||||
|---|---|---|
|
||||
| TAR to address (the RFM application or the applet's own) + MSL, or an explicit SPI1 | `scp80.params.tar` / `spi1` | the card preset must carry the TAR with its MSL, or the step passes `spi1`; RFM TARs: `B00000`/`B00001`/`B00010` compact, `B00120`/`B00140`/`B00130` expanded (see Worksheet 1) |
|
||||
| Keyset number | `scp80.params.kvn` (1–15) | only when the applet's keys are not the preset's first keyset |
|
||||
| Trigger C-APDUs (+ their format) | `scp80.params.apdu` / `format` | one secured packet per `scp80` step; the preset provides keys and counter, `format` wraps the APDU for an expanded-format application |
|
||||
| Expected file paths + contents | `file-read.params.path` + `check.data` | a mask must have **exactly** the actual value's length |
|
||||
| Expected proactive command(s) | `expect.command` + `qualifier`/`checks` | REFRESH `qualifier: "00"` = NAA init + full FCN, `files` asserts the changed EFs |
|
||||
| PoR transport + expected PoR | `scp80.check.por`/`check.data` (inline) or `expect SEND SHORT MESSAGE` + `por` check | pick per the SPI2 the applet expects |
|
||||
| Delivery timing | a `status` action when the card delivers on poll | the runner never polls by itself |
|
||||
|
||||
### The three status words (envelope SW · PoR status · R-APDU SW)
|
||||
|
||||
An SCP80 step carries three different "SW" values; keep them apart when
|
||||
writing checks:
|
||||
|
||||
| Where it comes from | What it means | Script field | Report row |
|
||||
|---|---|---|---|
|
||||
| the **ENVELOPE** exchange | the card's answer to the command that was sent (`9000` executed, `91XX` a proactive command is pending, `62XX`/`63XX` a warning) | the action's `check.sw` | `SW` |
|
||||
| the **PoR status** | the SCP80 security-processing verdict inside the response packet (`por_ok`, `cntr_low`, `rc_cc_ds_failed`, `tar_unknown`, …) | `por.status` (or `"por": "ok"`) | `PoR status` |
|
||||
| the **R-APDU SW** | the response payload's own status word - the last executed command's / GET RESPONSE's status in the compact form (TS 102 226 §5.1.2 Table 5.1), or the last R-APDU's in the scripting (`AB`/`AF`) form | `por.sw` | `PoR SW` |
|
||||
|
||||
`por.sw` is a child of the **response payload**, not of the envelope: it
|
||||
exists only when the addressed TAR is a **remote-management application**
|
||||
(ISD / RFM). A third-party applet's **own TAR** answers with
|
||||
application-defined bytes - the platform passes them through unchanged - so
|
||||
there is no R-APDU SW to check: `por.sw` reports `(none)` and the whole
|
||||
secured data is exposed as `por.data`. Assert such a response with `data`
|
||||
and leave `sw` empty; the step's `check.sw` still asserts the envelope
|
||||
exchange.
|
||||
|
||||
The form is decided by the command's **TAR** (v3.21.0): the preset's role
|
||||
TARs and the standard ISD/RFM allocations (TS 101 220 Annex D) are decoded as
|
||||
RM responses; any other TAR - an applet's own - is reported `raw` regardless
|
||||
of whether its bytes happen to parse as a compact response, so no status word
|
||||
is ever fabricated (a 2-byte reply cannot be one: Table 5.1 needs the command
|
||||
count plus two status bytes - it is reported raw too).
|
||||
|
||||
A `62XX`/`63XX` envelope answer does not guarantee a response packet: an
|
||||
application that refuses at the envelope level (e.g. an applet answering
|
||||
`6200` to an unknown command) sends none, while other warning paths still
|
||||
deliver one (the low-counter case arrives via SEND SHORT MESSAGE). For a
|
||||
step expected to be refused, leave `por` at its `"any"` default or assert
|
||||
`"por": "none"`; the run report's `PoR[…]` log line and `por` field show
|
||||
which happened.
|
||||
|
||||
### Worksheet 1 - RFM update → file change → REFRESH (full FCN)
|
||||
|
||||
```json
|
||||
{"name": "applet RFM update",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<RFM C-APDU>", "tar": "B00001", "kvn": 1, "spi2": "01"},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
|
||||
"label": "RFM update"},
|
||||
{"type": "expect", "command": "REFRESH", "qualifier": "00",
|
||||
"checks": [{"kind": "files", "value": "<changed FIDs, e.g. 3F007F206F07>"}],
|
||||
"respond": {"result": "ok"}},
|
||||
{"type": "action", "kind": "file-read", "params": {"path": "<EF path>"},
|
||||
"check": {"data": {"mode": "mask", "value": "<expected bytes, ? = wildcard>"}}}
|
||||
]}
|
||||
```
|
||||
|
||||
`tar` addresses the **RFM application that owns the file system**, not the
|
||||
applet-under-test: `B00000` = UICC shared FS, `B00001` = ADF RFM, `B00010` =
|
||||
SIM FS (compact), and `B00120`/`B00140`/`B00130` the expanded-format twins
|
||||
(TS 101 220 Annex D; the probe list in the SCP80 tab carries the same names).
|
||||
An applet that accepts secured packets under its **own** TAR - the TAR given
|
||||
in its INSTALL [for install] parameters (TS 102 226 §8.2.1.3.2.7) - uses that
|
||||
TAR instead. The preset must carry a TAR entry **with its MSL** or the step
|
||||
passes an explicit `spi1`; an expanded-format RFM application also expects the
|
||||
wrapped command template (`"format": "expanded"`/`"expanded-ae"`, see the
|
||||
actions table).
|
||||
|
||||
A **scripted SELECT inside the secured packet** should ask for no response
|
||||
data (`P2='0C'`, e.g. `00A4000C02A153`): a card can refuse the `P2='00'`
|
||||
form (return the FCI) with `6A86` when it arrives inside a command script,
|
||||
and the script has no use for an FCI anyway. The selects walk from the
|
||||
current directory the RFM application starts in: MF for the UICC shared FS
|
||||
RFM application (TAR `B00000`, TS 102 226 §7.2), the ADF for an ADF RFM
|
||||
application (TAR `B00001`, §7.3).
|
||||
|
||||
### Worksheet 2 - incoming data → particular PoR, no other actions
|
||||
|
||||
Inline PoR:
|
||||
|
||||
```json
|
||||
{"name": "applet data (inline PoR)",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<data C-APDU>", "tar": "<applet TAR>", "spi2": "01"},
|
||||
"check": {"sw": "9000", "por": "ok",
|
||||
"data": {"mode": "mask", "value": "<expected PoR bytes>"}}}
|
||||
]}
|
||||
```
|
||||
|
||||
PoR-in-submit:
|
||||
|
||||
```json
|
||||
{"name": "applet data (SEND SM PoR)",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<data C-APDU>", "tar": "<applet TAR>", "spi2": "21"},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
|
||||
{"type": "expect", "command": "SEND SHORT MESSAGE",
|
||||
"checks": [{"kind": "por", "status": "por_ok", "sw": "<R-APDU SW>",
|
||||
"data": {"mode": "mask", "value": "<expected response data>"}}],
|
||||
"respond": {"result": "ok"}}
|
||||
]}
|
||||
```
|
||||
|
||||
### Validation checklist
|
||||
|
||||
1. **Client-side**: the PWA import (or `testScriptProblem`) rejects structural
|
||||
mistakes; the server validates again with the same engine at run start.
|
||||
2. **Import** the JSON (PWA → Simulator → Test script → Import, or
|
||||
`POST /api/test/scripts/import`; a direct `POST /api/test/scripts` needs a
|
||||
`suite_id`), select the matched preset, **Run**.
|
||||
3. **Read the report**: every action/expectation shows expected vs actual per
|
||||
check; ⚠ = warning (continues), ✗ = error (stops). A failed step names the
|
||||
reason (`note`).
|
||||
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 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
|
||||
|
||||
Every step writes a semantic line to the server log under its step number,
|
||||
request before response:
|
||||
|
||||
```
|
||||
TEST-RUN step 1: SCP80 C-APDU=80E2900000
|
||||
TEST-RUN step 1: SCP80 SECURED=0100…1A2B (N B) TAR=B00001 SPI1=16 SPI2=21 CNTR=0000000A
|
||||
TEST-RUN step 1: SCP80 -> SW=9000
|
||||
TEST-RUN step 1: PoR[inline] status=por_ok TAR=B00001 CNTR=0000000A raw=0271…aabb
|
||||
TEST-RUN step 1: R-APDU SW=9000 data=AABB
|
||||
TEST-RUN step 2: STATUS 1/2 -> 9102
|
||||
TEST-RUN step 3: FETCH=8012000002 -> SW=9000 D03C8103012500…
|
||||
TEST-RUN step 3: CMD 0x25 SET UP MENU qual=00
|
||||
TEST-RUN step 3: TR=81030125000202828183020000 -> SW=9000
|
||||
TEST-RUN step 4: MENU-SELECT ENVELOPE=80c2000009d30702020181900101 item=1 ('One') -> SW=9103
|
||||
```
|
||||
|
||||
- A PoR delivered as SEND SHORT MESSAGE is logged on the step that fetches it:
|
||||
`PoR[sms-submit] …` followed by the same `R-APDU` line.
|
||||
- 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 BINARY <path>`,
|
||||
`READ RECORD <n> <path>`, `UPDATE BINARY <path>`, each with `-> SW=…`).
|
||||
- A `proactive-drain` action logs its rounds (`STATUS i/N -> …`, `FETCH=…`,
|
||||
`CMD 0x…`, `TR=… -> SW=…`); the run cleanup logs
|
||||
`TEST-RUN drain: <CMD> (0xNN) TR=… -> SW=…`.
|
||||
- A failing step adds `TEST-RUN step N failed (<level>): …` with the reason
|
||||
(`<level>` is `error` or `warning` - a tolerant step's line names its level).
|
||||
- The shared secured-packet sender logs each segment's answer
|
||||
(`OTA SEND: ENVELOPE i/N -> <SW>`).
|
||||
- `--apdu-trace` adds the raw transport view
|
||||
(`APDU-TRACE(…): <cmd> → SW: … RESP: …`) for every APDU, including the
|
||||
file-manager reads and pySim's auto-handler traffic.
|
||||
|
||||
## Document shape
|
||||
|
||||
```json
|
||||
{"name": "applet RFM update",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80", "params": {…}, "check": {…}, "label": "optional"},
|
||||
{"type": "expect", "command": "REFRESH", "qualifier": "00",
|
||||
"checks": [{"kind": "files", "value": "3F007F206F07"}],
|
||||
"respond": {"result": "ok"}}
|
||||
]}
|
||||
```
|
||||
|
||||
Every step may carry `on_fail`: `"error"` (default - stops the run) or
|
||||
`"warning"` (continues, the report shows ⚠). Each entry of `checks` may
|
||||
override it with its own `on_fail`. The level applies to **every** step
|
||||
failure, including an expectation that finds no pending command (v3.21.0)
|
||||
and a **card error raised during the step** (v3.22.7 - an absent optional
|
||||
file's `6A82`, a refused SELECT: with `on_fail: "warning"` the step warns,
|
||||
the report shows the error as a failed check, and the run continues) - the
|
||||
tolerant "consume a command if there is one" form is an expectation with
|
||||
`on_fail: "warning"`; to drain and confirm the card is idle, use the
|
||||
`proactive-drain` action.
|
||||
|
||||
## Presets: where the keys, TARs and counters come from
|
||||
|
||||
An SCP80 action never carries key material. The PWA sends the **stored
|
||||
preset's id** with the run; the server resolves it and takes:
|
||||
|
||||
- the keyset - the step's `kvn` (1–15) or the preset's first keyset,
|
||||
- the keys and the counter of that keyset,
|
||||
- the TAR table with each TAR's **MSL** (Minimum SPI1).
|
||||
|
||||
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. 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
|
||||
|
||||
```json
|
||||
{"type": "action", "kind": "…", "params": {…}, "check": {…}}
|
||||
```
|
||||
|
||||
| kind | params | notes |
|
||||
|---|---|---|
|
||||
| `envelope` | `event` 0–255, `data` hex (optional), optional `src` | ENVELOPE(Event Download) with raw data; `src` overrides the device-identities source (`82` terminal / `83` network) |
|
||||
| `event` | `event` (name or hex), `fields` (object) or `data` (form-built hex), optional `src` | semantic ENVELOPE(Event Download): the four server-built events from `fields`, every other event from the editor's `data` (see below) |
|
||||
| `menu-select` | `item_id` 1–255 **or** `text` + `mode` `exact`/`contains` (+ `case_sensitive: true` for an exact-case match — the default is case-insensitive; the step editor's **Match case** choice writes it), not both | ENVELOPE(Menu Selection); the text is resolved against the card's cached menu - refreshed whenever the card sends SET UP MENU, so it follows the applet's install parameters |
|
||||
| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD |
|
||||
| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
|
||||
| `apdu` | `apdu` hex | raw transport, no auto-handler |
|
||||
| `scp80` | `apdu` **xor** `sp` (optional `source`), `kvn`, `tar`, `spi1`, `spi2`, `format` | secured packet to the TAR; `format`: `compact` (default - the C-APDU verbatim), `expanded` (the `AA`-prefixed Command Scripting template) or `expanded-ae` (the `AE 80 … 00 00` form), TS 102 226 §5.2.1 |
|
||||
| `status` | `attempts` 1–1000, `interval_ms` 0–10000 | with `attempts > 1` the default SW check is `91??` (poll until a command) |
|
||||
| `proactive-drain` | `respond`, `first`, `attempts` 1–1000 (default 3), `interval_ms` (default 200), `require` | drain the card's announced proactive commands and confirm it is idle (see below) |
|
||||
|
||||
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
|
||||
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
|
||||
|
||||
`file-write` sends a direct terminal-side UPDATE BINARY/RECORD - it does **not**
|
||||
fire the card's EVENT_REMOTE_FILE_UPDATE (that event reports an update done by
|
||||
a remote entity through OTA/RFM, TS 102 223 §7.5.9): assert the change with
|
||||
`file-read` or the REFRESH `files` check.
|
||||
|
||||
### Semantic events (`kind: "event"`)
|
||||
|
||||
The step editor renders the **same form the Phone tab's send dialog uses**
|
||||
(`EVENT_FORMS`), for every modelled event - no release gating (a script may
|
||||
target another card). The four events the server builds (`0x03`/`0x0B`/`0x12`/
|
||||
`0x1D`) travel as `fields`: the server validates them at script load and builds
|
||||
the objects at run time (so the data-connection host clock is fresh). Every
|
||||
other event carries the form's built hex as `data` (the editor shows it live
|
||||
under the form, with the one-ENVELOPE check; the engine validates it and the
|
||||
single-envelope budget at load). The `src` device-identities source comes from
|
||||
the event itself (e.g. MT call and IMS registration are network-sourced) or
|
||||
from the form's source field; an explicit `src` overrides it. Both the
|
||||
`event` and the raw `envelope` action take the same optional `src`.
|
||||
|
||||
```json
|
||||
{"type": "action", "kind": "event",
|
||||
"params": {"event": "location_status",
|
||||
"fields": {"status": 0, "mcc": "250", "mnc": "01",
|
||||
"lac": "00FF", "cell": "0001"}}}
|
||||
```
|
||||
|
||||
The `event` value may be the code (a hex byte or a number) or one of the name
|
||||
keywords above; the editor resolves either form to the event - showing the
|
||||
right form and fields - and saves the hex byte form.
|
||||
|
||||
| `event` | fields |
|
||||
|---|---|
|
||||
| `location_status` / `0x03` | `status` 0 normal / 1 limited / 2 no service; for normal service `mcc`, `mnc`, `lac`, `cell` |
|
||||
| `access_tech` / `0x0B` | `tech` (TS 102 223 8.61 coding: 0 GSM, 3 UTRAN, 8 E-UTRAN, 10 NG-RAN, …) |
|
||||
| `network_rejection` / `0x12` | `reg_type` 0–17, `access_tech`, `cause`; the location object follows the registration group - LU: `lac`, GPRS: `lac`+`rac`, EPS/5GS: `tac` (4–6 hex) + optional `ext_cause`; `mcc`/`mnc`; optional `ext_info_type` (1 CAG ID / 2 NID / 3 RedCap) + `ext_info` |
|
||||
| `data_connection` / `0x1D` | `status` 0–2, `type` 0–2, optional `cause`, `ti` (hex byte), `datetime: "now"` (host clock, 8.39), `mcc`/`mnc`/`lac`/`cell`, `tech`, `loc_status`, `apn`, `pdp_type` |
|
||||
|
||||
`src` overrides the device-identities source (`82` terminal, `83` network);
|
||||
the editor writes the event's own source into the step (network for MT call /
|
||||
IMS registration, the form's choice otherwise) - a hand-written step without
|
||||
`src` is sent as the terminal (`82`).
|
||||
|
||||
### Proactive drain (`kind: "proactive-drain"`)
|
||||
|
||||
Consume whatever the card announces and leave it idle - at the start, in the
|
||||
middle or at the end of a script (a leftover command from a previous run must
|
||||
not derail the next step). The drain is also the tool for a command **the
|
||||
previous step left pending** (`91XX`): the runner's *unexpected proactive
|
||||
command pending* guard does not apply to it - its STATUS poll re-announces
|
||||
the unfetched command - so a `scp80`/`menu-select` step that answers `91XX`
|
||||
can be followed directly by a drain. Each round polls STATUS; a `91XX`
|
||||
answer fetches
|
||||
the command and answers it (the first command with `first`, the rest with
|
||||
`respond` - both `{"result": …}` like an expectation's response). The loop
|
||||
ends when STATUS answers `9000` (idle), a non-`91XX` SW appears or `attempts`
|
||||
rounds are spent; the **final SW is the step's `check.sw`** (default `9000`),
|
||||
so a still-pending card fails the step and hands the pending command to the
|
||||
next step.
|
||||
|
||||
```json
|
||||
{"type": "action", "kind": "proactive-drain",
|
||||
"params": {"respond": {"result": "ok"},
|
||||
"first": {"result": "cancel"},
|
||||
"attempts": 5, "interval_ms": 200,
|
||||
"require": {"command": "REFRESH", "qualifier": "00"}},
|
||||
"check": {"sw": "9000"},
|
||||
"label": "serve whatever is pending, leave the card idle"}
|
||||
```
|
||||
|
||||
- An **empty drain passes** (that is the point) - unless `require` is given:
|
||||
the step then fails with "nothing was pending".
|
||||
- `require` asserts **at least one** consumed command matches (`command` name
|
||||
or hex type, `ANY` allowed; optional `qualifier`, exact/mask).
|
||||
- `first` lets a script test the card's refusal path for the first command
|
||||
(e.g. `{"result": "cancel"}`) while the rest are answered `ok`.
|
||||
- Every consumed command is reported (type, qualifier, the TR's SW) and
|
||||
logged (`STATUS`/`FETCH`/`CMD`/`TR` lines under the step).
|
||||
|
||||
### `check` on actions
|
||||
|
||||
```json
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"},
|
||||
"data": {"mode": "exact", "value": "0271…"},
|
||||
"por": "ok"}
|
||||
```
|
||||
|
||||
- `sw` - default `{"mode": "exact", "value": "9000"}` (a `status` action with
|
||||
`attempts > 1` defaults to the `91??` mask). A GP INSTALL/LOAD/DELETE
|
||||
envelope answers `91XX` - the card announces a proactive command - so give
|
||||
those steps the `9???` mask and a `proactive-drain` after them (see
|
||||
*Driving a RAM install from a script*).
|
||||
- `data` - the response data (for SCP80: the inline PoR packet). The
|
||||
`proactive-drain` action does not take a `data` check - assert the drained
|
||||
commands with `require` and the final status with `sw`.
|
||||
- `por` (SCP80 actions only) - `"any"` (default, no check), `"ok"`
|
||||
(`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, `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
|
||||
wildcard) - e.g. `91??` matches `9102`, `027100????` matches a 4-byte
|
||||
prefix.
|
||||
|
||||
## Expectations
|
||||
|
||||
```json
|
||||
{"type": "expect", "command": "SEND SHORT MESSAGE",
|
||||
"qualifier": "00",
|
||||
"checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}],
|
||||
"respond": {"result": "ok"}}
|
||||
```
|
||||
|
||||
The card must have announced a command (`91XX`) in the previous step - the
|
||||
runner never polls by itself. `command` is a name (as shown in the proactive
|
||||
log) or a hex type; `ANY`/`*` skips the type check. `qualifier` is the
|
||||
command-details byte 3 (one byte, exact/mask).
|
||||
|
||||
When nothing is pending - or **a different command** is pending - the step
|
||||
fails at its own `on_fail` level (the default stops the run;
|
||||
`on_fail: "warning"` warns and continues: the tolerant consume-if-pending
|
||||
form, and the way to express "the applet either asks X or does Y" - the
|
||||
mismatched command is still fetched and answered with the step's `respond`). "Add a `status` action" applies
|
||||
when the card delivers on poll: `status` with `attempts > 1` polls until a
|
||||
command appears; it cannot make one appear when the card has none.
|
||||
|
||||
### Content checks
|
||||
|
||||
| kind | fields | checks |
|
||||
|---|---|---|
|
||||
| `text` | `mode` contains/exact, `value`, `case_sensitive` | the **Text string** (`8D`, DCS coded) of DISPLAY TEXT / GET INKEY / GET INPUT |
|
||||
| `alpha` | `mode` contains/exact, `value`, `case_sensitive` | the command's **Alpha identifier** (tag `05`/`85`, TS 102 223 §8.2, Annex A coded) - distinct from `text` when a command carries both |
|
||||
| `item` | `id`, `text`, `mode`, `case_sensitive` | items of SELECT ITEM / SET UP MENU |
|
||||
| `raw` | `mode` exact/mask, `value` | the whole fetched command (hex, exact length needed) |
|
||||
| `por` | `status`, `sw`, `data` | the PoR carried by a **SEND SHORT MESSAGE** (see below) |
|
||||
| `sms` | `da`, `pid`, `dcs`, `udl`, `ud` | the TPDU of a SEND SHORT MESSAGE (see below) |
|
||||
| `files` | `files` (list of path hex strings) or a comma/space separated string | the **File List** of a REFRESH, TS 102 223 §8.18; matched order-insensitively |
|
||||
|
||||
`por` needs an SCP80 step **before** the expectation: the TPDU of the SEND
|
||||
SHORT MESSAGE is decoded with that step's keyset, SPI and counter. `status`
|
||||
is the decoded response status name (`por_ok`, `cntr_low`,
|
||||
`rc_cc_ds_failed`, …; `ok` is accepted for `por_ok`); `sw` is the R-APDU
|
||||
status word **inside the response payload** - not the envelope's, see
|
||||
*The three status words* in the authoring section - and `data` the R-APDU
|
||||
response data (exact/mask); both come from
|
||||
the decoded response (scripting `AB`/`AF`/compact forms). An applet's **own
|
||||
TAR** answers with its application-defined bytes (the response form follows
|
||||
the command's TAR - see *The three status words*): its secured data is
|
||||
reported as `data` and no `sw` is decoded - assert such a response with
|
||||
`data` (the action's `por` object follows the same rule). The same raw
|
||||
fallback covers a remote-management reply that cannot be the compact
|
||||
structure - its command count exceeds the command script that was sent, or
|
||||
the data is too short for Table 5.1 (TS 102 226 §5.1.2). A PoR split over
|
||||
several SMS parts is accumulated across SEND SHORT MESSAGE expectations: put
|
||||
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 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;
|
||||
the OTA PoR of the preceding scp80 step is what `por` decodes. At least one
|
||||
field is required.
|
||||
|
||||
### `respond`
|
||||
|
||||
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` - a script alias or the two-digit hex value. The editor offers
|
||||
the **full TS 102 223 §8.12.0 list** (`00`–`3F`), labelled
|
||||
`<hex> - <alias>` where an alias exists, else `<hex> - <name>`, plus a
|
||||
**manual** hex entry for any other value. With a command selected (other
|
||||
than `ANY`) the list is **grouped** by the spec's applicability table
|
||||
(TS 102 223 Tables 6.1/6.2/6.3, "proactive commands versus possible terminal
|
||||
response"): the command-relevant values first, the rest after - nothing is
|
||||
hidden, the manual entry covers everything. Commands the table does not
|
||||
list (SEND SS / SEND USSD / SEND SHORT MESSAGE / GEOGRAPHICAL LOCATION
|
||||
REQUEST / End of the proactive UICC session) keep the flat list. The
|
||||
aliases:
|
||||
`ok`=00, `partial`=01, `missing`=02, `modified`=07, `cancel`=10,
|
||||
`back`=11, `timeout`=12, `refused`=22, `not_understood`=32 (and
|
||||
`no_response`=12, kept for compatibility only).
|
||||
**v3.23.5 corrected** `refused` (was `03`), `not_understood` (was `04`),
|
||||
`modified` (was `06`) and `no_response` (was `22`) to their spec meanings -
|
||||
a stored script using one of them now answers with the corrected value.
|
||||
- `additional_info` - the general result's additional-information byte (a hex
|
||||
byte, default `00`): the Result is sent in the general form
|
||||
`83 02 <result> <additional_info>`. TS 102 223 §8.12.2 defines the values
|
||||
for the terminal problem (`20`): `00` no specific cause, `01` screen busy,
|
||||
`02` busy on call, `04` no service, `05` access control class bar,
|
||||
`06` radio resource not granted, `07` not in speech call, `09` busy on
|
||||
SEND DTMF, `0A` no NAA active; the network problem (`21`) is NAA-specific
|
||||
(`00` = no specific cause). The editor suggests the §8.12.2 list.
|
||||
- `text` + `dcs` - the Text string for a GET INKEY / GET INPUT answer. `dcs`
|
||||
uses the spec's recommended codings: `"00"` GSM default alphabet 7 bits
|
||||
**packed**, `"04"` GSM default alphabet 8 bits (one octet per character),
|
||||
`"08"` **UCS2** (UTF-16-BE). An empty `text` sends the null text string
|
||||
(Length `00`). A text the selected `dcs` cannot code (a non-GSM character
|
||||
with `00`/`04`) is **refused at load** - the error names the coding and
|
||||
points at `dcs: "08"` (the run must not fail after the command was
|
||||
fetched).
|
||||
- `item_id` - the Item identifier for a successful SELECT ITEM answer.
|
||||
- `raw` - extra COMPREHENSION-TLVs appended after the standard objects.
|
||||
|
||||
The terminal's own command-specific objects ride with the scripted result
|
||||
unless `raw` overrides them: a successful PROVIDE LOCAL INFORMATION answer
|
||||
takes the TR Config **PLI dictionary** value for its qualifier - the entry
|
||||
or its pre-settled default (v3.23.1; the current date and time for the
|
||||
date/time qualifier `03`), and a POLL INTERVAL answer
|
||||
echoes the current interval (TS 102 223 §6.8.4) - the same objects the
|
||||
terminal's own answers carry.
|
||||
|
||||
UCS2 answer to a GET INPUT:
|
||||
|
||||
```json
|
||||
{"type": "expect", "command": "GET INPUT",
|
||||
"respond": {"result": "ok", "text": "Пароль", "dcs": "08"}}
|
||||
```
|
||||
|
||||
The `proactive-drain` action's `respond`/`first` use the same shape (the command
|
||||
type varies per drained command, so `text`/`item_id` apply where the command
|
||||
carries them).
|
||||
|
||||
### BIP commands in scripts
|
||||
|
||||
The card's own BIP commands (`OPEN CHANNEL` 0x40, `CLOSE CHANNEL` 0x41,
|
||||
`RECEIVE DATA` 0x42, `SEND DATA` 0x43, `GET CHANNEL STATUS` 0x44) are answered
|
||||
by the **terminal**, not by the script: during a run the channels really open,
|
||||
the data really flows to/from the configured BIP target, and the BIP events
|
||||
(Data available, Channel status) flow for the channels the card itself opened -
|
||||
so an applet's own HTTP/BIP conversation completes inside a scripted run. The
|
||||
BIP **control** API stays blocked while a run is active (409), like the
|
||||
terminal's other automatic actions (polling, menu). The one card-initiated
|
||||
automatic action that still waits is the **timer expiration** event: a
|
||||
TIMER MANAGEMENT timer the card armed is not fired during a run (it retries
|
||||
every 5 s and fires when the run ends) - a deliberate exception to the
|
||||
"the card's own conversation continues" rule, to review later (v3.25.3).
|
||||
|
||||
A step's `respond` overrides the terminal when it carries `raw` TLVs or a
|
||||
**non-ok result** - the way to script an applet error path:
|
||||
|
||||
```json
|
||||
{"type": "expect", "command": "OPEN CHANNEL",
|
||||
"respond": {"result": "refused"}}
|
||||
```
|
||||
|
||||
A plain `{"result": "ok"}` (or no `respond`) leaves the BIP answer to the
|
||||
terminal; the step log names it (`BIP: the terminal answered OPEN CHANNEL
|
||||
(0x40)`).
|
||||
|
||||
## The two PoR transports (SPI2)
|
||||
|
||||
1. **Inline** - the card answers the ENVELOPE `9000` with the PoR in the
|
||||
response data. Verify on the action: `"por": "ok"` and/or
|
||||
`"data": {"mode": "mask", "value": "…"}`, or assert the decoded PoR with
|
||||
`"por": {"status": "por_ok", "sw": "9000"}`.
|
||||
2. **PoR-in-submit** (SPI2 bit `20`, e.g. `spi2: "21"`) - the card announces
|
||||
a **SEND SHORT MESSAGE** (`91XX`); the PoR is inside its SMS TPDU:
|
||||
|
||||
```json
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "…", "tar": "B00001", "spi2": "21"},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
|
||||
{"type": "expect", "command": "SEND SHORT MESSAGE",
|
||||
"checks": [{"kind": "por", "status": "por_ok", "sw": "9000"}],
|
||||
"respond": {"result": "ok"}}
|
||||
```
|
||||
|
||||
If the card announces the SEND SHORT MESSAGE only on a later STATUS poll,
|
||||
insert a `status` action (`attempts` > 1) before the expectation.
|
||||
|
||||
## "No actions" after a trigger
|
||||
|
||||
An exact SW check is the assertion: `"check": {"sw": "9000"}` fails if the
|
||||
card answers `91XX` (a command was announced); the runner also errors on a
|
||||
pending command the next step does not expect (and drains it with `ok`, see
|
||||
*Run cleanup*).
|
||||
|
||||
## Worked examples
|
||||
|
||||
### Event download before an applet action
|
||||
|
||||
```json
|
||||
{"name": "applet event",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "event",
|
||||
"params": {"event": "location_status",
|
||||
"fields": {"status": 0, "mcc": "250", "mnc": "01",
|
||||
"lac": "00FF", "cell": "0001"}},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}}},
|
||||
{"type": "expect", "command": "ANY", "respond": {"result": "ok"}}
|
||||
]}
|
||||
```
|
||||
|
||||
The event's default SW check is an exact `9000`; a mask (`91??`) is needed
|
||||
when the applet answers with a proactive command, which the next expectation
|
||||
then fetches.
|
||||
|
||||
### Menu selection by text (the id varies with the install parameters)
|
||||
```json
|
||||
{"name": "applet menu",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "menu-select",
|
||||
"params": {"text": "Settings", "mode": "exact"}},
|
||||
{"type": "expect", "command": "ANY", "respond": {"result": "ok"}}
|
||||
]}
|
||||
```
|
||||
|
||||
The text is matched against the card's cached menu, **case-insensitively by
|
||||
default** (`case_sensitive: true` forces an exact-case match); the cache is
|
||||
refreshed whenever the card sends SET UP MENU (e.g. after an install +
|
||||
REFRESH). If the
|
||||
script must wait for the menu, add a `status` action and an
|
||||
`expect SET UP MENU` before the selection.
|
||||
|
||||
### RFM update → file change → REFRESH (NAA init + full FCN)
|
||||
|
||||
```json
|
||||
{"name": "applet RFM update",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"},
|
||||
"label": "RFM update"},
|
||||
{"type": "expect", "command": "REFRESH", "qualifier": "00",
|
||||
"checks": [{"kind": "files", "value": "3F007F206F07"}],
|
||||
"respond": {"result": "ok"}},
|
||||
{"type": "action", "kind": "file-read", "params": {"path": "MF/7F20/6F07"},
|
||||
"check": {"data": {"mode": "mask", "value": "AA??CC"}}}
|
||||
]}
|
||||
```
|
||||
|
||||
`qualifier` `00` is "NAA Initialization and Full File Change Notification";
|
||||
the `files` check asserts the exact set of changed files the applet reports.
|
||||
For this mode the terminal must **not** reset the card
|
||||
(TS 102 223 §6.4.7), so the run continues - the following `file-read`
|
||||
verifies the updated content.
|
||||
|
||||
### Driving a RAM install from a script
|
||||
|
||||
The RAM APDU list (`POST /api/scp81/gen-install`, or the PWA's *Install from
|
||||
.cap*) is a sequence of GP commands - INSTALL [for load], the LOAD blocks,
|
||||
INSTALL [for install], plus a DELETE first when the package is already
|
||||
loaded. Drive it from a test script as one `scp80` step per APDU, addressed
|
||||
to the preset's **ISD TAR** (`000000`; the preset supplies the keys, the
|
||||
counter and the SPI1 from that TAR's MSL):
|
||||
|
||||
```json
|
||||
{"name": "install applet",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<INSTALL [for load] C-APDU>", "tar": "000000"},
|
||||
"check": {"sw": {"mode": "mask", "value": "9???"}, "por": "any"},
|
||||
"label": "INSTALL [for load]"},
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<LOAD 1 C-APDU>", "tar": "000000"},
|
||||
"check": {"sw": {"mode": "mask", "value": "9???"}, "por": "any"},
|
||||
"label": "LOAD 1"},
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "<INSTALL [for install] C-APDU>", "tar": "000000"},
|
||||
"check": {"sw": {"mode": "mask", "value": "9???"}, "por": "any"},
|
||||
"label": "INSTALL [for install]"},
|
||||
{"type": "action", "kind": "proactive-drain", "params": {}}
|
||||
]}
|
||||
```
|
||||
|
||||
A successful GP step answers **`91XX`**, not `9000`: the card has a pending
|
||||
proactive command (the re-announced SET UP MENU). `XX` is that command's TLV
|
||||
length - `915d` for the 93-byte SET UP MENU, `910b` for an 11-byte PROVIDE
|
||||
LOCAL INFORMATION, `91b1` for a 177-byte SEND SHORT MESSAGE - so it can never
|
||||
be pinned exactly. The default exact-`9000` check therefore fails a step
|
||||
whose side effect succeeded; give every INSTALL/LOAD/DELETE step the `9???`
|
||||
mask. `por` stays `any` by default - assert the remote command's own status
|
||||
word with `{"por": {"status": "por_ok", "sw": "…"}}` when you want the
|
||||
stronger check.
|
||||
|
||||
The runner refuses to start an action while a command is pending: after
|
||||
**any** step that answered `91XX` the next step must be an `expect` or a
|
||||
`proactive-drain`. The card re-announces the menu after the delete and after
|
||||
the install, so a drain after those two groups is enough; a drain after any
|
||||
`91XX` step is always safe. The drain fetches the SET UP MENU, so the cached
|
||||
menu (text-based `menu-select`) is current afterwards.
|
||||
|
||||
### Incoming data → particular PoR, no other actions
|
||||
|
||||
Inline PoR:
|
||||
|
||||
```json
|
||||
{"name": "applet data (inline PoR)",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "01"},
|
||||
"check": {"sw": "9000", "por": "ok",
|
||||
"data": {"mode": "mask", "value": "027100????0102"}}}
|
||||
]}
|
||||
```
|
||||
|
||||
PoR-in-submit (with the transport asserted by the expectation):
|
||||
|
||||
```json
|
||||
{"name": "applet data (SEND SM PoR)",
|
||||
"steps": [
|
||||
{"type": "action", "kind": "scp80",
|
||||
"params": {"apdu": "80E2900000", "tar": "B00001", "spi2": "21"},
|
||||
"check": {"sw": {"mode": "mask", "value": "91??"}, "por": "none"}},
|
||||
{"type": "expect", "command": "SEND SHORT MESSAGE",
|
||||
"checks": [{"kind": "por", "status": "por_ok"}],
|
||||
"respond": {"result": "ok"}}
|
||||
]}
|
||||
```
|
||||
|
||||
## Run errors worth knowing
|
||||
|
||||
- `SCP80 preset is incomplete: …` - the matched preset has no complete
|
||||
keyset; `step N: keyset K is not defined in the preset` - the step's `kvn`
|
||||
is outside the preset's keysets.
|
||||
- `no MSL for TAR … - set it in the Cards tab or pass spi1` - the step's TAR
|
||||
has no entry in the preset and the step carries no `spi1`.
|
||||
- `no proactive command pending` - an expectation ran without a `91XX` from
|
||||
the previous step; it fails at the step's `on_fail` level (the check row
|
||||
names it). A `status` action with `attempts > 1` polls until a command
|
||||
appears when the card delivers on poll; `on_fail: "warning"` tolerates the
|
||||
empty case, and the `proactive-drain` action drains and confirms idle.
|
||||
- `6A82: … File not found` (or another raised card error) - the step's
|
||||
`on_fail` decides: the default stops the run, `on_fail: "warning"` warns
|
||||
and continues - the pattern for an **optional** file (`49AC` is documented
|
||||
optional: its absence means no menu password).
|
||||
- `menu-select: no menu item matches …` - the error lists the cached menu; a
|
||||
miss on an explicit `case_sensitive: true` names the case-insensitive
|
||||
candidates.
|
||||
- `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation
|
||||
expected a PoR that never arrived (check SPI2 and the transport).
|
||||
|
||||
## Run cleanup
|
||||
|
||||
When a run ends (an error, a stop request, or an unexpected pending command)
|
||||
with a command still announced, the runner fetches it and answers **`ok`
|
||||
(0x00)** and keeps draining while the TERMINAL RESPONSE answers `91XX`
|
||||
(bounded; each suite member ends the same way, before the next member starts)
|
||||
- a cancel (`0x10`) is not sent automatically: it tells the card
|
||||
the user aborted the proactive session, which can push assertive applications
|
||||
onto an error path and make them queue a further command. The cleanup is
|
||||
reported like a normal step (`TR ok …`, `pending REFRESH (0x01) answered with
|
||||
ok`); a script that wants to test the refusal path answers explicitly (an
|
||||
expectation's `respond`, or the `proactive-drain` action's `first`).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The runner executes one command at a time and never polls by itself; a
|
||||
proactive command that arrives only on a STATUS poll needs an explicit
|
||||
`status` action (or `proactive-drain` to consume it).
|
||||
- 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 every event the Simulator models: the four
|
||||
server-built events (0x03/0x0B/0x12/0x1D) from `fields`, every other event
|
||||
from the editor-built `data` hex (the raw `envelope` action stays for
|
||||
hand-built data).
|
||||
+346
-174
File diff suppressed because one or more lines are too long
+332
-161
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OTAMan — Help</title>
|
||||
<title>SIMple — Help</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="bg-neutral-50 dark:bg-slate-900 text-gray-800 dark:text-slate-200">
|
||||
@@ -16,12 +16,12 @@
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 min-w-0 px-6 py-8">
|
||||
<h1 class="text-2xl font-bold text-heading mb-6">OTAMan <span class="text-xs text-gray-400 dark:text-slate-500 ml-2">Documentation</span></h1>
|
||||
<h1 class="text-2xl font-bold text-heading mb-6">SIMple <span class="text-xs text-gray-400 dark:text-slate-500 ml-2">Documentation</span></h1>
|
||||
<section class="mb-10">
|
||||
<h2 id="overview" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">1. Overview</h2>
|
||||
<p class="mb-3">OTAMan is a Progressive Web App (PWA) for building APDU commands, assembling SCP80 secured packets, browsing the SIM Toolkit (STK) menu, and simulating a real network environment against a SIM/USIM/UICC card via a PC/SC reader.</p>
|
||||
<p class="mb-3">The application is a single static <code class="font-mono text-sm">index.html</code> file. All computation runs in the browser; the card is accessed through a local HTTP server (<code class="font-mono text-sm">pysim-otaman-server</code>) that wraps the <a href="https://osmocom.org/projects/pysim" class="text-blue-600 dark:text-blue-400 hover:underline">pySim</a> library.</p>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">Browser (OTAMan PWA) → HTTP :8080 → pysim-otaman-server → pySim → PC/SC → card reader → UICC/SIM</pre>
|
||||
<p class="mb-3">SIMple is a workbench for UICC/SIM card specialists: an offline Progressive Web App (PWA) covering the full lab cycle — an <strong>APDU workbench</strong> (SIM/USIM RFM and Expanded Script builders, GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder), an <strong>OTA lab</strong> (SCP80 secured packets and Java Card RAM installation, plus the terminal side of HTTP OTA — SCP81: BIP channel emulation, a PSK TLS administration server and APDU script execution), a <strong>phone/CAT simulator</strong> (SIM Toolkit menu, proactive commands and events, TERMINAL PROFILE editing, the C-AT dialogue log) and a <strong>card profiler</strong> (profiles and immutable snapshots with timings, exact snapshot comparison, FCP/FCI decoding).</p>
|
||||
<p class="mb-3">The application is a single static <code class="font-mono text-sm">index.html</code> file. All computation runs in the browser; the card is accessed through a local HTTP server (<code class="font-mono text-sm">pysim-simple-server</code>) that wraps the <a href="https://osmocom.org/projects/pysim" class="text-blue-600 dark:text-blue-400 hover:underline">pySim</a> library.</p>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">Browser (SIMple PWA) → HTTP :8080 → pysim-simple-server → pySim → PC/SC → card reader → UICC/SIM</pre>
|
||||
<p class="mb-3">Standards referenced across the application:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li>ETSI TS 102 221 — UICC-Terminal interface (CLA 00, USIM files)</li>
|
||||
@@ -35,18 +35,29 @@
|
||||
<li>3GPP TS 23.038 — GSM 7-bit alphabet and DCS</li>
|
||||
<li>3GPP TS 24.008 / 24.301 / 24.501 — NAS cause codes</li>
|
||||
<li>GlobalPlatform Card Specification v2.3.1</li>
|
||||
<li>GlobalPlatform GPC v2.2 Amendment B v1.1 — Remote Application Management over HTTP</li>
|
||||
<li>ISO/IEC 7816-4 — commands for interchange</li>
|
||||
<li>ISO/IEC 9797-1 — MAC algorithms</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="interface" class="text-lg font-medium mb-2">1.1 Interface</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle. Next to the title a small status indicator shows the server/card state (gray dot = connecting, red dot = no server connection, card icons = no card / equipping / equipped), the equipped card's <strong>ICCID</strong> digits, a compact <strong>ADM ✓</strong> (green, administrator PIN verified) or <strong>ADM ✗</strong> (red, not verified) badge for the current card session — with a trailing key glyph (<strong>ADM ✓ ⚿</strong>) when the card preset matching that ICCID carries an ADM key — and gray <strong>SCP80</strong> / <strong>SCP81</strong> markers that appear when that preset has complete SCP80 (a keyset plus the three TAR entries with their MSLs) or SCP81 (PSK identity/key) settings.</li>
|
||||
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
|
||||
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>Push commands</strong>, <strong>Parser</strong> (<strong>C-APDU Parser</strong>, <strong>R-APDU parser</strong>)), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>RAM</strong>, <strong>TAR probe</strong>), <strong>SCP81</strong> (<strong>Listener</strong>, <strong>Scripts</strong>), <strong>Cards</strong>, <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Simulator</strong>.</li>
|
||||
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens §7).</li>
|
||||
<li>The browser <strong>Back</strong> / <strong>Forward</strong> buttons walk the app’s own view stack — every tab, pill and list/editor switch is recorded (session-only, the URL never changes); the in-app <em>Cancel</em>/<em>Back</em> buttons replace the current entry instead of stacking a new one.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. C-APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, and <strong>C-APDU Parser</strong>.</p>
|
||||
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Remote APDU tab</h2>
|
||||
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations, command sets and decoding tools: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>RAM/GP</strong>, <strong>Expanded Script</strong>, <strong>Push commands</strong>, and <strong>Parser</strong> — the last with the <strong>C-APDU Parser</strong> and <strong>R-APDU parser</strong> sub-pills.</p>
|
||||
|
||||
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p>
|
||||
<p class="mb-2">Commands are assembled as a <strong>chain</strong>: press a <code class="font-mono text-sm">+ Command</code> button to append a row, fill that row’s fields, and the chain preview (above the pack button) updates automatically. Add a <strong>GET RESPONSE</strong> row to fetch data following a SELECT. Press <strong>Pack into Secured packet</strong> to wrap the whole chain into an SCP80 packet.</p>
|
||||
<p class="mb-2">Commands are assembled as a <strong>chain</strong>: press a command button (e.g. <code class="font-mono text-sm">+ SELECT</code>) to append a row, fill that row’s fields, and the chain preview (above the pack button) updates automatically. Add a <strong>GET RESPONSE</strong> row to fetch data following a SELECT. Press <strong>Pack into Secured packet</strong> to wrap the whole chain into an SCP80 packet; packing sets the SCP80 TAR to the selected preset’s <strong>UICC RFM TAR</strong> (<code class="font-mono text-sm">B00000</code> by default), the UICC Shared File System RFM TAR: the MF is implicitly selected and no ADF can be accessed, so SELECT paths address files from MF.</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700">
|
||||
<th class="text-left py-1 px-2">Command</th><th class="text-left py-1 px-2">INS</th><th class="text-left py-1 px-2">Description</th>
|
||||
@@ -65,6 +76,8 @@
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">DISABLE PIN</td><td class="py-1 px-2 font-mono">26</td><td class="py-1 px-2">Disable a PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">ENABLE PIN</td><td class="py-1 px-2 font-mono">28</td><td class="py-1 px-2">Enable a PIN</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">UNBLOCK PIN</td><td class="py-1 px-2 font-mono">2C</td><td class="py-1 px-2">Unblock a PIN with PUK</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SEARCH RECORD (SEEK)</td><td class="py-1 px-2 font-mono">A2</td><td class="py-1 px-2">Search a linear fixed EF for a pattern; P2 selects type 1/2 and the mode (TS 151 011 §9.2.7)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INCREASE</td><td class="py-1 px-2 font-mono">32</td><td class="py-1 px-2">Add a value to the last increased/updated record of a cyclic EF (3-byte value, TS 151 011 §9.2.8)</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2">Fetch data indicated by a preceding <code class="font-mono text-sm">61XX</code>/<code class="font-mono text-sm">9FXX</code> status word</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -81,12 +94,26 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">For record commands, the P2 mode is <strong>Absolute (04)</strong>, <strong>Next (02)</strong>, or <strong>Previous (03)</strong>. When a Case 4 command is immediately followed by a GET RESPONSE row, the chain builder strips its trailing Le byte automatically (ETSI TS 102 226 §5.1.1). A conversion panel is embedded in the right column (IMSI, MSISDN, ICCID, SPN, PLMN, nibble swap). See <a href="#conversion" class="text-blue-600 dark:text-blue-400 hover:underline">§2.5</a>.</p>
|
||||
<p class="text-sm mb-3"><strong>→ Expanded Script</strong> sends the built chain to the Expanded Script builder as C-APDU rows (GET RESPONSE hops are dropped — the expanded format does not use them, TS 102 226 §5.2.1.1). The UICC Shared File System RFM application (TAR <code class="font-mono text-sm">B00000</code>) is reachable with either the compact or the expanded format — unless the TAR is configured for automatic format detection, the two use different TAR values (TS 102 226 §7.0/§5.3).</p>
|
||||
|
||||
<h4 id="rfm-file-picker" class="font-medium mb-1">SELECT file picker</h4>
|
||||
<p class="text-sm mb-2">Every SELECT row has a file picker below the method fields: a filter box, an <strong>all files</strong> toggle, the session start root with the current DF, and the file list grouped by root (<code class="font-mono text-sm">MF</code>, <code class="font-mono text-sm">ADF.USIM</code>, …). Picking a file fills the fields for the current method; when the method cannot express the selection the picker switches to one that can — preferring <strong>path</strong> (one SELECT), else <strong>chain</strong> — and says so in the note line, e.g. a file under another DF becomes <code class="font-mono text-sm">7F106F40</code> (path from MF) or <code class="font-mono text-sm">7F20,6F46</code> (relative FID chain).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>List source</strong>: with a card equipped the loaded file-manager tree wins (card names and probed presence; probed-absent files are hidden). Without a card, or for branches the tree has not loaded, the shipped standard list is used — <code class="font-mono text-sm">uicc_files.json</code>, generated from pySim's profiles/application classes by <code class="font-mono text-sm">pysim_simple_server/uicc_files.py</code> and kept in sync by a test. The list is specs-default until you run <strong>Probe all files</strong> in the file manager. Custom files are always merged.</li>
|
||||
<li><strong>Session start</strong>: the implicit current DF depends on the <em>TAR</em> the secured packet is sent to — the UICC Shared File System RFM application starts in MF (TS 102 226 §7.2), an ADF RFM application in its ADF (§7.3). The picker defaults to MF for both builders and can be changed per row (select the ADF to work in an ADF session).</li>
|
||||
<li><strong>Path</strong> is the FID sequence from MF <em>without</em> the MF identifier (ISO 7816-4: “select from the MF — path without the MF identifier”); the USIM <strong>from current DF</strong> variant uses the relative tail. <strong>Chain</strong> stays relative to the current DF and follows the FID search order of TS 102 221 §11.1.1.2 (children → parent → siblings), so it never needs a hop to the common ancestor itself.</li>
|
||||
<li>ADF roots are not pickable: an application is selected by AID, which the <strong>By AID</strong> method keeps manual (TS 102 226 §7.1 forbids P1=<code class="font-mono text-sm">04</code> for RFM).</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="usim-rfm" class="text-lg font-medium mb-2">2.2 USIM RFM</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">00</code> (ETSI TS 102 221). Same chain builder and command set as SIM. Differences:</p>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">00</code> (ETSI TS 102 221). Same chain builder and command set as SIM, plus the UICC administrative commands, and the TS 102 221 forms of SEARCH RECORD / INCREASE:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>SELECT</strong> requests FCP by default (P2=<code class="font-mono text-sm">04</code>) and appends Le=<code class="font-mono text-sm">00</code>; a <strong>Silent (P2=0C)</strong> checkbox selects without requesting FCP (no Le, no response data).</li>
|
||||
<li><strong>By path</strong> offers <strong>from MF</strong> (P1=<code class="font-mono text-sm">08</code>) or <strong>from current DF</strong> (P1=<code class="font-mono text-sm">09</code>).</li>
|
||||
<li><strong>By path</strong> offers <strong>from MF</strong> (P1=<code class="font-mono text-sm">08</code>) or <strong>from current DF</strong> (P1=<code class="font-mono text-sm">09</code>); the current DF of an ADF RFM session is ADF.USIM.</li>
|
||||
<li><strong>SEARCH RECORD</strong> (TS 102 221 §11.1.7) offers the simple modes (P2=<code class="font-mono text-sm">10</code>/<code class="font-mono text-sm">12</code>, P1 = record number) and the enhanced mode (P2=<code class="font-mono text-sm">18</code>) with the 2-byte search indication; <strong>INCREASE</strong> (§11.1.8) takes an optional SFI and the value to add.</li>
|
||||
<li><strong>CREATE FILE</strong> / <strong>DELETE FILE</strong> / <strong>RESIZE FILE</strong> (ETSI TS 102 222 §6.3/6.4/6.10) take an FCP template; <strong>Build template</strong> composes a structural skeleton (file descriptor, FID, LCSI, size, and a permissive compact access rule <code class="font-mono text-sm">8C 02 7F 00</code> — review it for your card). RESIZE FILE uses CLA <code class="font-mono text-sm">80</code> (TS 102 222 Table 1: <code class="font-mono text-sm">'8X'</code>/<code class="font-mono text-sm">'CX'</code>).</li>
|
||||
<li><strong>SET DATA</strong> / <strong>RETRIEVE DATA</strong> (TS 102 221 §11.3) operate on BER-TLV EFs: P2 selects first/next/retransmit block (+SFI), the tag field is optional (<code class="font-mono text-sm">5C</code> lists the allocated tags).</li>
|
||||
<li><strong>Pack into Secured packet</strong> sets the SCP80 TAR to the selected preset’s <strong>ADF RFM TAR</strong> (<code class="font-mono text-sm">B00001</code> by default), linked to the ADF’s AID (ADF.USIM).</li>
|
||||
<li>Each SELECT hop in a chain requests FCP unless marked silent.</li>
|
||||
</ul>
|
||||
|
||||
@@ -103,17 +130,17 @@
|
||||
<th class="text-left py-1 px-2">Type</th><th class="text-left py-1 px-2">Tag</th><th class="text-left py-1 px-2">Description</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">C-APDU</td><td class="py-1 px-2 font-mono">22</td><td class="py-1 px-2">Raw APDU hex</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">C-APDU</td><td class="py-1 px-2 font-mono">22</td><td class="py-1 px-2">Any remote management command: raw APDU hex or a command built with the SIM RFM / USIM RFM / RAM-GP editors (TS 102 226 §5.2.1.0; GET RESPONSE is not used in this format, §5.2.1.1)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Immediate Action</td><td class="py-1 px-2 font-mono">81</td><td class="py-1 px-2">Proactive command or action indicator</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Error Action</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2">Conditional error recovery with action indicator or proactive command</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Multi-packet script execution with First/Intermediary/Last flags</td></tr>
|
||||
<tr><td class="py-1 px-2">Response Type</td><td class="py-1 px-2 font-mono">-</td><td class="py-1 px-2">Expanded/Compact/None response parsing indicator</td></tr>
|
||||
<tr><td class="py-1 px-2">Script Chaining</td><td class="py-1 px-2 font-mono">83</td><td class="py-1 px-2">Multi-packet script execution with First/Intermediary/Last flags</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">The Immediate Action builder offers an action indicator (<code class="font-mono text-sm">81</code>/<code class="font-mono text-sm">82</code>), a structured proactive command builder (REFRESH, DISPLAY TEXT, PLAY TONE with auto-generated COMPREHENSION-TLV objects), or a freeform hex input.</p>
|
||||
<p class="text-sm mb-3">A <strong>C-APDU</strong> row starts in <strong>Hex</strong> mode (paste any command) and can switch to a one-row <strong>SIM RFM</strong>, <strong>USIM RFM</strong> or <strong>RAM/GP</strong> editor that builds the command from fields — the resulting APDU is wrapped in the <code class="font-mono text-sm">22</code> TLV and shown next to the picker. GET RESPONSE is not offered there: the expanded format carries no GET RESPONSE (TS 102 226 §5.2.1.1). <strong>→ Expanded Script</strong> in the SIM RFM / USIM RFM / RAM-GP views appends their built commands as C-APDU rows.</p>
|
||||
<p class="text-sm mb-3">The Immediate Action builder offers an action indicator (<code class="font-mono text-sm">81</code>/<code class="font-mono text-sm">82</code>), a structured proactive command builder (REFRESH, DISPLAY TEXT, PLAY TONE with auto-generated COMPREHENSION-TLV objects), or a freeform hex input. Only those three proactive commands are allowed as an Immediate Action (TS 102 226 Table 5.5) — the behaviour of the card for any other proactive command is undefined.</p>
|
||||
|
||||
<h4 id="ber-error-action" class="font-medium mb-2 text-base">Error Action TLV (Tag 82)</h4>
|
||||
<p class="text-sm mb-2">Error recovery per TS 102 226 §5.2.1.3 — one of three forms:</p>
|
||||
<p class="text-sm mb-2">Error recovery per TS 102 226 §5.2.1.3 — one of four forms:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Proactive command:</strong> COMPREHENSION-TLV set with DISPLAY TEXT or PLAY TONE (only these two are allowed in an Error Action, TS 102 226 Table 5.9)</li>
|
||||
<li><strong>No action:</strong> <code class="font-mono text-sm">82 00</code></li>
|
||||
@@ -122,22 +149,17 @@
|
||||
</ul>
|
||||
|
||||
<h4 id="ber-script-chaining" class="font-medium mb-2 text-base">Script Chaining TLV (Tag 83)</h4>
|
||||
<p class="text-sm mb-2">Multi-packet script execution with context preservation:</p>
|
||||
<p class="text-sm mb-2">Multi-packet script execution with context preservation (TS 102 226 §5.2.1.4). The TLV is <code class="font-mono text-sm">83 01 <flag></code>, present once and only as the first Command TLV; it may be used for RFM and RAM only:</p>
|
||||
<ul class="text-sm list-disc pl-5 mb-2">
|
||||
<li><strong>Chaining Flags:</strong> <code class="font-mono text-sm">01</code> first script (delete chaining info on reset), <code class="font-mono text-sm">11</code> first script (keep chaining info across reset, RFM only), <code class="font-mono text-sm">02</code> subsequent script (more to follow), <code class="font-mono text-sm">03</code> subsequent script (last)</li>
|
||||
<li><strong>Script ID:</strong> Correlation identifier across packets (1-4 bytes hex, auto-increment hints provided)</li>
|
||||
<li><strong>Additional Data:</strong> Extended chaining information (optional hex)</li>
|
||||
<li><strong>Context Preservation:</strong> UICC keeps security/transaction state open across chained scripts</li>
|
||||
<li><code class="font-mono text-sm">01</code> — first script, delete chaining information upon card reset</li>
|
||||
<li><code class="font-mono text-sm">11</code> — first script, keep chaining information across card reset (RFM only)</li>
|
||||
<li><code class="font-mono text-sm">02</code> — subsequent script, more to follow</li>
|
||||
<li><code class="font-mono text-sm">03</code> — subsequent script, last one</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">With chaining the command session is extended beyond one scripting template: the UICC keeps the file/PIN context (RFM) or the application session (RAM, e.g. a chained STORE DATA sequence) until the last script.</p>
|
||||
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Expanded Remote Response (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Per-command results with error details and chaining context:</p>
|
||||
<ul class="text-sm list-disc pl-5">
|
||||
<li>Command number, status word, response data for each command</li>
|
||||
<li>Error code and error info for failed commands (highlighted in red)</li>
|
||||
<li>Script ID and position for chained script correlation (ID, FIRST, LAST)</li>
|
||||
<li>Response type indicator: 'expanded' vs 'compact' vs 'none'</li>
|
||||
</ul>
|
||||
<h4 id="expanded-response" class="font-medium mb-2 text-base">Response decoding (TS 102 226 §5.2.2)</h4>
|
||||
<p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command’s status word and response data filled into the <strong>R-APDU parser</strong> panel (Remote APDU → <strong>Parser</strong> pill).</p>
|
||||
|
||||
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
|
||||
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management. Built with the same chain builder as SIM/USIM: add rows, fill fields, and the chain preview updates automatically.</p>
|
||||
@@ -157,6 +179,8 @@
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">GET DATA</td><td class="py-1 px-2 font-mono">CA</td><td class="py-1 px-2 font-mono">tag</td><td class="py-1 px-2">Read card data objects</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">STORE DATA</td><td class="py-1 px-2 font-mono">E2</td><td class="py-1 px-2 font-mono">00/40/80/C0/E0</td><td class="py-1 px-2">Store data (key, certificate, …)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SET STATUS</td><td class="py-1 px-2 font-mono">F0</td><td class="py-1 px-2 font-mono">80/40/60</td><td class="py-1 px-2">Lifecycle state management</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">PUT KEY</td><td class="py-1 px-2 font-mono">D0</td><td class="py-1 px-2 font-mono">key ver</td><td class="py-1 px-2">Replace a key: P1 = key version, P2 = key identifier, data = new key material (GP Card Spec §11.8)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">PUSH</td><td class="py-1 px-2 font-mono">EC</td><td class="py-1 px-2 font-mono">01</td><td class="py-1 px-2">Ask an application to open a BIP channel / CAT_TP link / TCP connection or send an identification packet (TS 102 226 §9)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">EXTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">82</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">SCP host authentication</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">INTERNAL AUTHENTICATE</td><td class="py-1 px-2 font-mono">88</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Card challenge-response</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">GET RESPONSE</td><td class="py-1 px-2 font-mono">C0</td><td class="py-1 px-2 font-mono">00</td><td class="py-1 px-2">Fetch data after a <code class="font-mono text-sm">61XX</code> status (Le configurable)</td></tr>
|
||||
@@ -192,6 +216,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">Refer to GlobalPlatform v2.3.1 and ETSI TS 102 226 §8.2.1.3.2 for the full GET STATUS P1/P2, GET DATA tag, DELETE P1, STORE DATA P1, and SET STATUS tables.</p>
|
||||
<p class="text-sm mb-3"><strong>PUSH</strong> (TS 102 226 §9.2) is a C-APDU (<code class="font-mono text-sm">80 EC 01 P2</code>) addressed to an application that supports BIP and/or CAT_TP — not the HTTP administration trigger (that is the HTTP OTA builder above). P2: <code class="font-mono text-sm">01</code> BIP channel opening (OPEN CHANNEL COMPREHENSION-TLVs, optional), <code class="font-mono text-sm">02</code> CAT_TP link (destination port in transport level <code class="font-mono text-sm">3C</code> with protocol type 00, optional buffer size <code class="font-mono text-sm">39</code> / channel data <code class="font-mono text-sm">36</code>), <code class="font-mono text-sm">03</code> TCP connection (bearer <code class="font-mono text-sm">35</code>, transport level <code class="font-mono text-sm">3C</code> with protocol type 02 = TCP client remote, destination address <code class="font-mono text-sm">3E</code>, NAA/APN <code class="font-mono text-sm">47</code>), <code class="font-mono text-sm">04</code> identification packet (channel data <code class="font-mono text-sm">36</code>; the ICCID is used when absent). The application issues the proactive OPEN CHANNEL itself; success is <code class="font-mono text-sm">90 00</code>, failure <code class="font-mono text-sm">6F 00</code> with the Result TLV in the response data, and malformed data is rejected with <code class="font-mono text-sm">6A 80</code>. P1 <code class="font-mono text-sm">80</code> is reserved for application-specific usage.</p>
|
||||
|
||||
<h3 id="conversion" class="text-lg font-medium mb-2">2.5 Conversion (SIM/USIM sidebars)</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
@@ -204,40 +229,66 @@
|
||||
</ul>
|
||||
|
||||
<h3 id="c-apdu-parser" class="text-lg font-medium mb-2">2.6 C-APDU Parser</h3>
|
||||
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 §5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements.</p>
|
||||
<p class="text-sm mb-3">Pastes raw APDU hex and renders a collapsible tree. It auto-detects the container: an <strong>Expanded Script</strong> (leading <code class="font-mono text-sm">AA</code> or <code class="font-mono text-sm">AE80</code>, decoded per ETSI TS 102 226 §5.2.1) or a <strong>Compact C-APDU chain</strong> (a sequence of ISO 7816 C-APDUs). Each node shows its label, hex and a short description; parent nodes expand to reveal their sub-elements. The <strong>C-APDU Parser</strong> and <strong>R-APDU parser</strong> are the two sub-pills of the <strong>Parser</strong> pill.</p>
|
||||
|
||||
<h3 id="http-ota" class="text-lg font-medium mb-2">2.7 HTTP OTA</h3>
|
||||
<h3 id="push-commands" class="text-lg font-medium mb-2">2.7 Push commands</h3>
|
||||
<p class="text-sm mb-3">Groups the commands that make the card dial out. Three sub-pills switch between the forms — <strong>HTTP OTA Trigger (Push SMS)</strong>, <strong>HTTP OTA Store (SD admin params)</strong> and <strong>Channel / link trigger</strong> — and only the selected form is shown; the two GP forms share one set of fields — the selected mode switches the form heading and its mode-specific rows (the SD params tag in Store, the Command Scripting template in Trigger) and wraps the payload accordingly. They come from different specs and are delivered differently: the administration trigger is a TLV message for the Security Domain (HTTP OTA), a §9 PUSH is a C-APDU (<code class="font-mono text-sm">80 EC 01 P2</code>) for an application that supports BIP and/or CAT_TP.</p>
|
||||
|
||||
<h4 id="http-ota" class="font-medium mb-1">Administration session (HTTP OTA)</h4>
|
||||
<p class="text-sm mb-3">Builds the Remote Application Management over HTTP payloads defined in GlobalPlatform <strong>GPC v2.2 Amendment B v1.1</strong> (§4.7). Two modes:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Trigger (Push SMS)</strong> — administration session triggering parameters (<code class="font-mono text-sm">81 > 83 > 84/[85]/[86]/89</code>, Table 4-3). This is the message that asks the card's Security Domain to dial out and start an HTTP session.</li>
|
||||
<li><strong>Store (SD admin params)</strong> — writes the same parameters as card (Security Domain) data via <strong>STORE DATA in TLV mode</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = last block + BER-TLV per GP v2.2 Amendment B v1.1.3), wrapped in tag <code class="font-mono text-sm">85</code> (or <code class="font-mono text-sm">A5</code>) per Table 4-4.</li>
|
||||
<li><strong>Store (SD admin params)</strong> — writes the same parameters as card (Security Domain) data via <strong>STORE DATA in TLV mode</strong> (<code class="font-mono text-sm">80 E2 90 00</code>, P1=90 = last block + no encryption + BER-TLV per GP Card Spec v2.3.1 §11.11; data above a short APDU is chained as P1.b8=0 blocks), wrapped in tag <code class="font-mono text-sm">85</code> per Table 4-4 (<code class="font-mono text-sm">A5</code> is offered as an unverified variant).</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">Sections mirror the spec tables:</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Section</th><th class="text-left py-1 px-2">Tag</th><th class="text-left py-1 px-2">Contents</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Connection parameters</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Any COMPREHENSION-TLV needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities <code class="font-mono text-sm">02</code>, Alpha <code class="font-mono text-sm">80</code>, Bearer <code class="font-mono text-sm">01</code>, vendor TLVs. Row editor + presets, editable hex.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Connection parameters</td><td class="py-1 px-2 font-mono">84</td><td class="py-1 px-2">Any COMPREHENSION-TLV needed to open the TCP connection (OPEN CHANNEL per TS 102 223): Device Identities <code class="font-mono text-sm">82</code>, Alpha <code class="font-mono text-sm">05</code>, Command details <code class="font-mono text-sm">81</code>, Bearer <code class="font-mono text-sm">35</code> (<code class="font-mono text-sm">03</code> = default bearer), vendor TLVs. Row editor + presets, editable hex.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Security parameters</td><td class="py-1 px-2 font-mono">85</td><td class="py-1 px-2">Table 4-6: LV PSK Identity (text), LV Key version/KID. Identifies the PSK TLS key (RFC 4279).</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Retry policy</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Table 4-7: retry counter (2 bytes, e.g. <code class="font-mono text-sm">B000</code>), retry waiting delay as the TS 102 223 timer TLV (<code class="font-mono text-sm">25 03 HH MM SS</code>), optional vendor-specific report-failure TLV.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Retry policy</td><td class="py-1 px-2 font-mono">86</td><td class="py-1 px-2">Table 4-7: retry counter (2 bytes, e.g. <code class="font-mono text-sm">0003</code>), retry waiting delay as the TS 102 223 timer TLV (<code class="font-mono text-sm">25 03 HH MM SS</code>) with TP-SCTS semi-octet fields (TS 23.040 §9.1.2.3): <code class="font-mono text-sm">00 10 00</code> = 1 min, <code class="font-mono text-sm">00 00 02</code> = 20 s; optional report-failure TLV.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">HTTP POST</td><td class="py-1 px-2 font-mono">89</td><td class="py-1 px-2">Tables 4-8/9/10: Host header (<code class="font-mono text-sm">8A</code>), X-Admin-From agent ID (<code class="font-mono text-sm">8B</code>), URI (<code class="font-mono text-sm">8C</code>) — text converted to octets.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-2"><strong>Command Scripting template</strong> (checkbox) wraps the whole <code class="font-mono text-sm">81</code> triggering command in the definite-length Expanded Remote Application data format (<code class="font-mono text-sm">AA</code>, ETSI TS 102 226 §5.2.1) for TARs that process the expanded format (RAM-over-HTTP §4.7).</p>
|
||||
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling — insert the TAR the SD listens on (typically the OTASD TAR) there.</p>
|
||||
|
||||
<h4 id="push-ts102226" class="font-medium mb-1 mt-3">TS 102 226 §9 PUSH</h4>
|
||||
<p class="text-sm mb-2">The <strong>Channel / link trigger</strong> sub-pill builds the §9 requests with the same encoder as the RAM/GP chain's <strong>PUSH</strong> row, previews the C-APDU(s), and offers <strong>Pack into Secured packet</strong> (the TAR stays manual: the request is addressed to the target application, not the Security Domain, and a typed TAR is kept when sending) and <strong>→ Expanded Script</strong> (wraps each APDU in the <code class="font-mono text-sm">22</code> Command TLV and appends them to the Expanded Script builder).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>BIP channel opening</strong> (<code class="font-mono text-sm">01</code>) — built in <strong>Fields</strong> mode from the most-used OPEN CHANNEL parameters (Stepping Stones §18.6.1): bearer description, transport protocol (UDP or TCP, UICC client, remote — the only values the command allows) with the destination port, destination address, NAA/APN, buffer size and alpha (a <em>transparent</em> checkbox sends the null alpha, i.e. no user confirmation); login/password and anything else go in the extra-TLVs field. The <strong>local address is never emitted</strong> (§9.2.1 and the guide both forbid it). <strong>Hex</strong> mode takes the raw COMPREHENSION-TLVs instead. The application issues the proactive OPEN CHANNEL itself (§9.1.1). SIMalliance Stepping Stones R7 §18.6 recommends sending it <strong>together with the CAT_TP request in the same message</strong> (the channel parameters ride in <code class="font-mono text-sm">01</code>, the CAT_TP port in <code class="font-mono text-sm">02</code>), so the form has an <em>Also request the CAT_TP link establishment (02)</em> checkbox; the pair is one concatenated command script (concatenated SMS when it exceeds 140 octets).</li>
|
||||
<li><strong>CAT_TP link establishment</strong> (<code class="font-mono text-sm">02</code>) — destination port in transport level <code class="font-mono text-sm">3C</code> with protocol type 00 (mandatory), optional buffer size <code class="font-mono text-sm">39</code> / identification data <code class="font-mono text-sm">36</code>. Standalone it relies on card-provisioned channel/CAT_TP defaults, which the Stepping Stones guide advises against; prefer the pair above.</li>
|
||||
<li><strong>TCP connection</strong> (<code class="font-mono text-sm">03</code>) — bearer <code class="font-mono text-sm">35</code>, transport level <code class="font-mono text-sm">3C</code> with protocol type 02 = TCP client remote, destination address <code class="font-mono text-sm">3E</code> (<code class="font-mono text-sm">21</code> IPv4 / <code class="font-mono text-sm">57</code> IPv6 / <code class="font-mono text-sm">F0</code> FQDN), NAA/APN <code class="font-mono text-sm">47</code>; port and address are mandatory. These are the OPEN CHANNEL TCP parameters and they are mandatory in the request, so <code class="font-mono text-sm">03</code> is <strong>self-contained</strong> — it needs no companion <code class="font-mono text-sm">01</code>. The application establishes the connection over BIP or, where the card and terminal support it, over a direct IP connection (TS 102 483).</li>
|
||||
<li><strong>Identification packet</strong> (<code class="font-mono text-sm">04</code>) is not a trigger: it presupposes an already open TCP channel and only makes sense as a follow-up in the same message, so the TCP request carries an <em>Also send the identification packet (04)</em> checkbox that appends it (optional data; the ICCID is used when empty). <code class="font-mono text-sm">04</code> also stays available in the RAM/GP chain's PUSH row for script sequences.</li>
|
||||
<li><strong>PoR</strong> — Stepping Stones R7 §18.6.3 recommends <em>not</em> requesting a PoR for a successful push (it may disturb the ME–card communication); request one only to investigate a failure, in SMS-SUBMIT mode (SCP80 SPI2 bit <code class="font-mono text-sm">0x20</code>).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
|
||||
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 R-APDU parser</h3>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command’s status word and response data after a successful “Send to Card” (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">§3.1</a>).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
<li><strong>AID names</strong> — AID-carrying tags (<code class="font-mono text-sm">4F</code>, <code class="font-mono text-sm">84</code>, <code class="font-mono text-sm">C4</code>, <code class="font-mono text-sm">CC</code>) are annotated with the standard library package name when the AID is a known JavaCard / ETSI / 3GPP / GlobalPlatform package (or a RID owner hint).</li>
|
||||
</ul>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2>
|
||||
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>Cards</strong>, and <strong>RAM</strong>. Assembles SCP80 secured packets per ETSI TS 102 225.</p>
|
||||
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>RAM</strong> and <strong>TAR probe</strong>. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its <strong>EF.ICCID</strong> (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.</p>
|
||||
|
||||
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
|
||||
<p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225.</p>
|
||||
<p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225. The <strong>TAR</strong> selector lists the selected preset's TAR entries (role label or description, MSL and default keyset); choosing one applies that TAR's <strong>default keyset</strong> to the form (its KIc/KID and keys — v3.26.0: the keyset selector is gone, the TAR drives it) and seeds the SPI1 from its <strong>MSL</strong> (Minimum SPI1, TS 102 226 §8.2.1.3.2.4 — the card checks it before the security processing and answers <code class="font-mono text-sm">0A</code> when the packet's SPI1 is below it). A value below the MSL shows an amber warning under the form. The start button stays disabled until a card preset is selected and the operation TAR's keyset is usable: a TAR whose MSL requires security (any non-zero MSL — the counters are keyset-linked) must have a default keyset in the preset, while an MSL 00 TAR may go keyless or still carry one (some cards reject the no-security <code class="font-mono text-sm">00/00</code> KIc/KID form). The send is refused when the preset does not define the named keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). The <strong>Counter</strong> field is an override: it starts empty and shows the preset's next counter as its placeholder — an empty field lets the server seed the packet from the stored counter and persist the value the card accepted; a typed value is used for that send only, is never written to the preset, and a lower one never rewinds the stored counter (only forward moves are persisted).</p>
|
||||
<h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Size</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CPI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Identifier (02)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CPL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Packet Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CPL</td><td class="py-1 px-2">2 (SMS)</td><td class="py-1 px-2">Command Packet Length (CHL..end incl. padding)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHI</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Identifier (01)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CHL</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Command Header Length</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">SPI</td><td class="py-1 px-2">2</td><td class="py-1 px-2">Security Parameter Indicator</td></tr>
|
||||
@@ -246,7 +297,7 @@
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">TAR</td><td class="py-1 px-2">3</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">CNTR</td><td class="py-1 px-2">5</td><td class="py-1 px-2">Replay counter</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">PCNTR</td><td class="py-1 px-2">1</td><td class="py-1 px-2">Padding counter</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">RC/CC/DS</td><td class="py-1 px-2">8</td><td class="py-1 px-2">Cryptographic Checksum / MAC</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">RC/CC/DS</td><td class="py-1 px-2">4–8</td><td class="py-1 px-2">Redundancy Check (CRC-32) / Cryptographic Checksum (MAC) / Digital Signature</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">Secured Data</td><td class="py-1 px-2">var.</td><td class="py-1 px-2">Padded APDU (encrypted if required)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -256,38 +307,30 @@
|
||||
<li><strong>AES-CBC</strong> encryption (zero ICV, zero-padded to 16), 16/24/32-byte keys (TS 102 225 §5.1.2, KIc <code class="font-mono text-sm">x2</code>)</li>
|
||||
<li><strong>Retail MAC</strong> (ISO 9797-1 MAC algorithm 3) for the DES/3DES cryptographic checksum</li>
|
||||
<li><strong>AES-CMAC</strong> (NIST SP 800-38B, truncated to 8 octets) for the AES cryptographic checksum (TS 102 225 §5.1.3.1, KID <code class="font-mono text-sm">x2</code>)</li>
|
||||
<li><strong>Redundancy Check (RC)</strong> — CRC-32 over the same header frame as the CC (TS 102 225 §5.1.3.2; needs no key, SPI1 b2b1 = <code class="font-mono text-sm">01</code>). Only CRC-32 is implemented, matching pySim; the KID's CRC-16 coding is not offered.</li>
|
||||
<li>AES requires a replay-protected counter: SPI1 bits b5 b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 §5.1.2/§5.1.3.1</li>
|
||||
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server).</p>
|
||||
<p class="text-sm mb-3">A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet — the server persists the accepted counter into the preset, the form’s counter field stays empty (its placeholder follows the stored value) unless the operator typed an override. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately. The card never reports its counter (the PoR’s CNTR field is a copy of the packet’s), so the notice offers a bounded <strong>Sync counter</strong> probe — a read-only command with increasing counter values (doubling steps, a 32-bit ceiling, at most 40 packets) until the card accepts one, then accepted+1 is stored (a search stopped by a security verdict after a counter rejection saves the value too, with an amber warning to check the security settings) — and a <strong>Go to preset</strong> button for raising the counter by hand; nothing else is written automatically.</p>
|
||||
|
||||
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Cards</h3>
|
||||
<p class="mb-2">Stores card presets locally in the browser (<code class="font-mono text-sm">localStorage</code>) so the Secured Packet view can auto-fill keys and parameters.</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">SPI1 / SPI2</td><td class="py-1 px-2">Security Parameter Indicators</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR</td><td class="py-1 px-2">Toolkit Application Reference</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter</td><td class="py-1 px-2">Replay counter (5 bytes)</td></tr>
|
||||
<tr><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3"><strong>Export as JSON</strong> / <strong>Import JSON from clipboard</strong> share presets. The selected card preset auto-fills the Secured Packet form.</p>
|
||||
|
||||
<h3 id="ram" class="text-lg font-medium mb-2">3.3 RAM</h3>
|
||||
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> sub-tab provides the SPI, keys, TAR, and counter.</p>
|
||||
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
|
||||
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the keys, the TAR table and the counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default; the SPI1 is derived from that TAR's MSL). v3.26.0: the operation's keyset is the ISD TAR entry's <strong>default keyset</strong> — the keyset selector is gone — and the server resolves it (and its keys) from the preset store. The start button stays disabled until a card preset is selected and the ISD TAR's keyset is usable: a non-zero MSL requires a default keyset, an MSL 00 TAR may go keyless. The <strong>Format</strong> selector pins the remote command format: <strong>Auto (detect)</strong> probes the read-only GET STATUS command compact and in both expanded codings and keeps the first that the card executes, <strong>Compact</strong> sends the bare C-APDU, and <strong>Expanded (AA, definite length)</strong> / <strong>Expanded (AE 80, indefinite length)</strong> wrap it in the TS 102 226 §5.2.1 scripting template (both execute on the live ISD; a template whose declared lengths do not match its body draws a Bad format TLV, which the operation shows as the step failure). The listing and registry queries follow that format: compact listings keep the chained GET RESPONSE, while an expanded run sends the same P2=00 queries wrapped and unchained (the reference TCA loader’s form, Le=’00’ returns the listing directly), and a registry query the card refuses in the current format is retried in the other formats — the report names the one that served it. A <strong>next-occurrence page</strong> (<code class="font-mono text-sm">P2=03</code>) that a format refuses is retried in the other formats too — the expanded codings first (the serving one first when it is expanded), the compact command string last, because a compact page attempt can consume the page without delivering it (the card answers <code class="font-mono text-sm">61xx</code> and the chained GET RESPONSE fails; every later format then sees the occurrence already gone) — some cards serve the expanded next page only wrapped in the <code class="font-mono text-sm">AA</code> template — and a listing is marked <code class="font-mono text-sm">(first page only)</code> only when the card promised more (<code class="font-mono text-sm">6310</code>) and no format could fetch the follow-up; in <strong>Auto</strong> the explorer also queries entries the bulk listing left without details individually by AID (a few extra packets). The card-resources block also names the card’s inferred <strong>ETSI release</strong> (<code class="font-mono text-sm">uicc.toolkit</code> version → REL-N, the same mapping as the CAP analysis; shown only when the expanded registry returned the versions). A package whose own listing carries no version derives it from its applet instance when one exists — the package’s module AIDs are the applet instance AIDs, and the row is annotated as coming from the app instance — so a card that refuses every tag-list form still shows package versions wherever an applet instance exists.</p>
|
||||
|
||||
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr>
|
||||
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)].</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). The compact listing carries the AID, life cycle and privileges (the legacy format has no version); if a card rejects it (<code class="font-mono text-sm">P2.b2=0</code>, deprecated by GP) the queries are retried in the expanded <code class="font-mono text-sm">E3</code> form, and the <strong>Fetch expanded registry details (load-file / SD associations)</strong> checkbox also requests it as an extra pass and merges the expanded-only fields: the Executable Load File version, Load File AID, Security Domain AID, module lists, the applet STK menu entries (SCP Registry Data, TS 102 226 8.2.1.6) and any other registry data objects the card returns (shown raw, never dropped) — it costs a few extra secured packets per Explore and is <strong>on by default</strong> (remembered per browser; untick to skip). Cards differ in what they accept, so the expanded query is tried in three forms — the reference tag list (with the TS 102 226 <code class="font-mono text-sm">EA</code>), the same list without <code class="font-mono text-sm">EA</code>, and no tag list at all (the GlobalPlatformPro form) — and the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). When the ELF rows still carry no version, the tag-list forms are retried for both P1=10 and P1=20, skipping the combinations already refused, and the walk stops as soon as a version appears. Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item <strong>Delete</strong> buttons and the detected RAM command format (hover for what compact vs expanded means).</td></tr>
|
||||
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version → REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click — the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) — a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card — the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word — the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports “already processed” — restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package — no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' — a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
|
||||
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends — the run itself is server-side and survives a reload. After a finished run the panel’s <strong>Show status</strong> button reopens the report.</p>
|
||||
|
||||
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
||||
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
||||
@@ -296,100 +339,92 @@
|
||||
<li><strong>Applications</strong> — AID, lifecycle, privileges, associated ELF/SD. Each has a <strong>Delete</strong> button (GP <code class="font-mono text-sm">DELETE</code> by AID).</li>
|
||||
<li><strong>Executable Load Files</strong> — AID, lifecycle, version, module AIDs. Each has <strong>Delete</strong> (ELF only) and <strong>Delete All</strong> (cascade: ELF + modules + installed Applications, P2=<code class="font-mono text-sm">0x80</code>) buttons.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Standard package AIDs are annotated with their library name (JavaCard / ETSI / 3GPP / GlobalPlatform packages, plus the default GlobalPlatform ISD AID); unknown and vendor AIDs stay bare.</p>
|
||||
<p class="text-sm mb-3">Delete confirms via a browser prompt before sending the GP <code class="font-mono text-sm">DELETE</code> command via SCP80. The explorer auto-refreshes after a successful deletion.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Response parser tab</h2>
|
||||
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, …) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
|
||||
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
|
||||
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
|
||||
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
|
||||
</ul>
|
||||
<h3 id="tar-probe" class="text-lg font-medium mb-2">3.3 TAR probe</h3>
|
||||
<p class="mb-2">The third SCP80 pill probes <strong>which OTA applications the card has registered</strong>: it sends <strong>one secured packet per selected TAR</strong> with a harmless C-APDU (default <code class="font-mono text-sm">SELECT MF</code>). v3.26.0: the rows are the selected preset's TAR list (no arbitrary TARs), each row uses its own <strong>MSL</strong> and its own <strong>default keyset</strong> (resolved from the preset store — the probe's keyset selector is gone), and a TAR whose MSL requires security without a default keyset is refused before anything is sent. The resolved SPI1 must carry a counter check (b5b4 ≠ 00) and request a PoR. The <strong>Execute</strong> button stays disabled until a card preset is selected.</p>
|
||||
<p class="mb-2">The list shows the selected preset's TAR entries (all checked; <strong>Select all</strong> / <strong>Select none</strong> switch them quickly); each row's tooltip names its MSL and default keyset.</p>
|
||||
<p class="mb-2">Every TAR is classified from the card's answer: <strong>registered</strong> (ENVELOPE 9000 + a PoR carrying the application's own status word and data), <strong>accepted, no answer</strong> (a PoR without an R-APDU), <strong>accepted, no PoR</strong> (the packet was silently dropped) and <strong>refused</strong> (a non-9000 ENVELOPE, typically <code class="font-mono text-sm">6200</code> for a TAR without a registered application). The result table appears in the operation popup; the line under Execute summarises <code class="font-mono text-sm">registered N / M</code>.</p>
|
||||
<p class="mb-2">Each packet the card accepts <strong>consumes a counter</strong>, which is saved into the preset's keyset — the probe is refused when the resolved SPI1 has no counter check, when the preset lacks the row's default keyset (a non-zero MSL requires one) or the entry names an undefined keyset number. Observed on a live card: <code class="font-mono text-sm">000000</code>, <code class="font-mono text-sm">B00000</code> and <code class="font-mono text-sm">B00001</code> registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with <code class="font-mono text-sm">6200</code>.</p>
|
||||
|
||||
<h2 id="cards" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Cards tab</h2>
|
||||
<p class="mb-2">Stores card presets on the server (<code class="font-mono text-sm">~/.pysim-simple-server/card_presets.json</code>; <code class="font-mono text-sm">--card-presets</code> overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself — a closed tab or a second browser window can no longer lose an increment. A preset carries <strong>several keysets</strong> — a keyset's number is the <code class="font-mono text-sm">b8..b5</code> nibble of its KIc/KID (TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same number (01–0F; A.2 rejects a mismatch), and each keyset has its own counter on the card (Annex A.1), so counters are tracked per keyset. In the form the keysets are edited as rows — one per number, with <strong>Add keyset</strong> / <strong>Remove keyset</strong>; each row shows the number derived from its KIc/KID byte, and <code class="font-mono text-sm">00</code> (no security) is not accepted as a keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab: it shows the <strong>preset list</strong> by default, and <strong>Add preset</strong> / <strong>Edit</strong> open the <strong>editor</strong> (a separate view) — <strong>Save</strong> stores the preset and returns to the list, <strong>Cancel</strong> discards the edits. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in all three SCP80 views; when the equipped card matches <strong>no</strong> preset — or the selected preset belongs to another card — all three SCP80 views show an amber warning above the preset selector. The list highlights the equipped card's preset. The editor groups the fields into bordered blocks: <strong>PIN/ADM</strong> (the optional card codes — PIN1/PUK1/PIN2/PUK2 as 4–8 decimal digits and the ADM key — stored for later use), <strong>TAR</strong> (the TAR table — each entry carries its TAR, its MSL and its <strong>default keyset</strong>: the keyset every operation addressed to that TAR uses; mandatory for a non-zero MSL — the counters are keyset-linked — optional for MSL 00, and a preset with a single keyset fills it automatically), <strong>SCP80 (GSM 03.48, ETSI TS 102 225)</strong> (the keysets and their counters) and <strong>SCP81 (HTTP OTA)</strong> (the PSK identity/key pair with the key-selection note). The editor's header row also carries the card's <strong>CAT (USAT) release</strong> — the release guard hides CAT items introduced after it (the Simulator's event parameters); unspecified means Rel-12, and an unset release never filters anything away. The header shows gray <strong>SCP80</strong>/<strong>SCP81</strong> markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.</p>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Name</td><td class="py-1 px-2">Human-readable label (required)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ICCID</td><td class="py-1 px-2">Optional card identifier. <strong>From card</strong> fills it with the equipped card's EF.ICCID (enabled only while a readable ICCID is known); saving refuses an ICCID that already exists in the list (compared ignoring spaces and the raw-hex form, and the conflicting preset is named)</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PIN1 / PUK1 / PIN2 / PUK2 / ADM</td><td class="py-1 px-2">Optional card codes, all stored for later use (nothing consumes them yet). PIN/PUK must be 4–8 decimal digits (saving refuses anything else); ADM is the administrator key (4–16 hex digits, e.g. a decimal PIN), used by the file manager. None is used by the SCP80/SCP81 views</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc / KID</td><td class="py-1 px-2">Key and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">TAR / MSL / Description</td><td class="py-1 px-2">The TAR table: one uniform row per TAR with its MSL (Minimum SPI1, TS 102 226 §8.2.1.3.2.4). The first three rows are the fixed role entries (pre-settled, not removable) with their fixed descriptions; further TARs are added with <strong>Add TAR</strong>, carry an optional description and can be removed. A packet's SPI1 is derived from its TAR's MSL</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ISD compact format</td><td class="py-1 px-2">Issuer Security Domain TAR (TS 101 220 Annex D); used for RAM/GP operations (SCP80 RAM view, RAM/GP chains); spec default <code class="font-mono text-sm">000000</code>, prefilled when empty</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">UICC shared FS RFM compact format</td><td class="py-1 px-2">UICC Shared File System RFM TAR (TS 102 226 §7.2, TS 101 220 Annex D): the MF is implicitly selected and only files under the MF are accessible (no ADFs); used by the SIM RFM view; spec default <code class="font-mono text-sm">B00000</code></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">ADF FS RFM compact format</td><td class="py-1 px-2">ADF RFM TAR (TS 102 226 §7.3, TS 101 220 Annex D): the ADF is implicitly selected and the TAR is linked to the application's AID — ADF.USIM in the USIM RFM view; spec default <code class="font-mono text-sm">B00001</code></td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Counter (hex)</td><td class="py-1 px-2">Replay counter (5 bytes); one per keyset, advanced and saved by the server after each operation that consumes it</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">KIc key / KID key</td><td class="py-1 px-2">16/24/32 hex chars (8/16/24-byte 3DES) or 32/48/64 hex chars (16/24/32-byte AES) keys</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">PSK identity</td><td class="py-1 px-2">SCP81 HTTP OTA: the identity the card sends in the TLS handshake (e.g. <code class="font-mono text-sm">89390100000129506903</code>)</td></tr>
|
||||
<tr><td class="py-1 px-2">PSK key</td><td class="py-1 px-2">SCP81 HTTP OTA: 32 hex chars (16 bytes) for the PSK TLS listener, picked by the identity the card presents</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">The <strong>SCP81</strong> column shows whether the preset supplies a usable PSK pair: <strong>✓</strong> (green) — identity and key set; <strong>⚠</strong> (amber) — only one of the two, so the listener ignores the preset; <strong>—</strong> — no PSK. PSK identity and key must be set together. <strong>Add preset</strong> opens the editor with a fresh preset; <strong>Edit</strong> loads the row's preset into it so fields can be changed without re-entering the card; <strong>Save</strong> stores it and returns to the list, <strong>Cancel</strong> discards the edits; <strong>Remove</strong> deletes the row (list only). Presets can be shared with <strong>Export to file</strong> and restored with <strong>Import from file</strong>. The selected card preset auto-fills the Secured Packet form and sets the TAR for the operation (RAM/GP → ISD TAR, SIM RFM → UICC RFM TAR, USIM RFM → ADF RFM TAR); a TAR typed into the Secured Packet form by hand is kept when sending - the counter, keys and the TAR's MSL are still re-read from the preset; the SPI1 is seeded from the TAR's MSL and a lower value warns (the server returns it as a <code class="font-mono text-sm">warning</code> too); edits are pushed into a running SCP81 listener automatically.</p>
|
||||
<p class="text-sm mb-3"><strong>Import outfile</strong> imports many cards at once from a card vendor's <em>outfile</em> — the personalization tool's plain-text dump: a banner and a configuration header, then a whitespace-separated <strong>column line</strong> (the tokens include <code class="font-mono text-sm">ICCID</code>; the first token may carry a <code class="font-mono text-sm">var_out:</code>-style prefix) and one row per card. Everything before the column line and every unused column is ignored. Every data row must carry a value for <strong>every declared column</strong> — the format is positional, so an empty value is indistinguishable from a missing one — and a <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> pair named in the header must have a usable value in every row; a violation makes the whole file invalid (the preview reports the line and the import is refused). The parser reads <code class="font-mono text-sm">ICCID</code> (digits, the digits with an odd-nibble <code class="font-mono text-sm">F</code> pad, spaced digits or the raw EF hex — all normalised to one form), <code class="font-mono text-sm">PIN1</code>/<code class="font-mono text-sm">PIN2</code>/<code class="font-mono text-sm">PUK1</code>/<code class="font-mono text-sm">PUK2</code>, <code class="font-mono text-sm">ADM</code> (the key's hex value, 4–16 hex digits in byte pairs — e.g. <code class="font-mono text-sm">3632333239463337</code>), <code class="font-mono text-sm">PSK</code> (16 bytes; an optional <code class="font-mono text-sm">PSKID</code>/<code class="font-mono text-sm">PSK_ID</code> names its identity, defaulting to the card's ICCID) and the numbered key pairs <code class="font-mono text-sm">KICn</code>/<code class="font-mono text-sm">KIDn</code> — one keyset per pair, up to 15. The <strong>KIc/KID bytes are never in the file</strong>: the keyset number is the column number (<code class="font-mono text-sm">KIC2</code> → keyset 2) and the algorithm nibble follows the key size — 8 bytes DES, 24 bytes 3DES3, and for <strong>16 bytes</strong> the preview's choice, <strong>3DES2</strong> (default) or <strong>AES</strong>; any other size skips that keyset with a note. The TARs never come from the file either: the preview shows the TAR table the imported cards get (the three role entries with their spec TARs and MSL 16 by default, editable, with <strong>+ TAR</strong> for extra entries) and <strong>every row carries its own keyset</strong> — a select over the keysets found in the file (the first one is preselected; a non-zero MSL needs one, marked amber), so each TAR's packets use exactly the keyset you choose. A <strong>common name</strong> is applied to every imported card (empty falls back to the ICCID); <strong>Import mode</strong> chooses <strong>Merge</strong> (default — existing presets are kept, duplicate ICCIDs are skipped and reported) or <strong>Replace</strong> (the store is wiped first). The preview lists the parsed cards (ICCID, keysets, the codes found) and every skipped row/key with its reason before anything is stored.</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Card reader (pySim) tab</h2>
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations. Sub-tabs: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>Profiler</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>, and <strong>Proactive UICC</strong>.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 File manager</h3>
|
||||
<h2 id="scp81" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. SCP81 tab</h2>
|
||||
<p class="mb-3">Drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B): the card's BIP channel is redirected to a target the server listens on (or to an external platform), and the terminal side of the administration session is emulated. Two pills: <strong>Listener</strong> and <strong>Scripts</strong>.</p>
|
||||
<h3 id="scp81-listener" class="text-lg font-medium mb-2">5.1 Listener</h3>
|
||||
<p class="mb-2"><strong>Start</strong> / <strong>Stop</strong> control the target; <strong>Restart script</strong> queues the selected script again with <code class="font-mono text-sm">force</code>, restarting its run from the first APDU. Modes:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back</li>
|
||||
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
||||
<li><strong>PSK TLS server</strong> (default) — a PSK TLS listener on <strong>Host</strong>:<strong>Port</strong> that answers with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (<code class="font-mono text-sm">X-Admin-*</code> headers, <code class="font-mono text-sm">200</code> with a command string or <code class="font-mono text-sm">204 No Content</code>). PSK keys come from the card presets (<strong>Cards</strong> tab): the listener picks the key by the identity the card sends in the handshake, and <strong>Start</strong> is refused when no preset has both parts. The key is never stored or logged; <code class="font-mono text-sm">tls-psk-unknown</code> marks an unrecognised identity.</li>
|
||||
<li><strong>Redirect to external server</strong> — no local listener: every BIP channel is connected to the configured target (<strong>Host</strong> and <strong>Port</strong> required), which terminates TLS and runs the administration dialog; the address the card requests is only logged.</li>
|
||||
<li><strong>Pass-through (card destination)</strong> — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + Transport level port, TCP client remote only; the specs define no default port, so an incomplete request fails the channel). TLS is terminated by that platform, and the server's network is used (lab only).</li>
|
||||
<li><strong>Capture (dump)</strong> — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.</li>
|
||||
</ul>
|
||||
<p class="mb-2">The <strong>Options</strong> block (collapsed by default; a <em>custom</em> marker appears when anything differs from the reference defaults; applied at <strong>Start</strong>, remembered in the browser) exposes the HTTP framing — <strong>Chunked body</strong> / <strong>chunk size</strong> (0 = the whole response in one TLS record), the Connection header, compact headers (omitting the optional space after <code class="font-mono text-sm">:</code>, legal per RFC 7230 §3.2) and the Next-URI (checkbox + template; unchecked = one-shot: the card executes the command, returns no response string and closes the session) — and the script framing (indefinite <code class="font-mono text-sm">AE 80 …</code> / definite <code class="font-mono text-sm">AA</code> Command Scripting template, comprehension-required tags, and the optional <code class="font-mono text-sm">X-Admin-Targeted-Application</code> with its own checkbox). <strong>Show link events</strong> (Channel status ENVELOPEs, TS 102 223 §7.5.11) applies to every mode. The connection is kept open between POSTs and closed cleanly at the 204. TLS itself has no settings: the listener offers TLS 1.0–1.2 and all PSK suites and lets the card negotiate; the state line and the TLS log show the version/cipher actually used, and a handshake that fails for a TLS/cipher reason is logged as <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
|
||||
<p class="mb-2"><strong>Script</strong> selects the command list served over the session — <strong>None</strong> (leave the server's configured script) or one of the scripts from the <strong>Scripts</strong> pill; the chosen list is sent to the server when the listener starts.</p>
|
||||
<p class="text-sm mb-3">The state line shows the listener, the negotiated identity and live channels (bytes in/out). <strong>Script results (R-APDUs)</strong> lists each served C-APDU with its R-APDU/SW and a <em>done/total</em> progress (GET STATUS listings annotate known standard package AIDs with their library name); the <strong>HTTP OTA log</strong> records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), with <strong>Clear</strong>.</p>
|
||||
<h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Scripts</h3>
|
||||
<p class="text-sm mb-2">Named APDU lists stored locally (<code class="font-mono text-sm">simple_scripts</code>) and sent to the server when a listener starts. Each list is a sequence of hex C-APDUs (one per line; <code class="font-mono text-sm">#</code>/<code class="font-mono text-sm">;</code> comments allowed). <strong>New</strong> creates one from a template:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Empty</strong> — start from scratch.</li>
|
||||
<li><strong>Explore ISD</strong> — the reference administration sequence (<code class="font-mono text-sm">GET DATA FF21</code>, GET STATUS ISD/ELF/application listings, <code class="font-mono text-sm">GET DATA 0085</code>); long listings auto-continue through the <code class="font-mono text-sm">SW 6310/CAFE</code> pages.</li>
|
||||
<li><strong>Install from .cap</strong> — pick a <code class="font-mono text-sm">.cap</code> plus optional SD AID, install/STK parameters and <em>make selectable</em>; <strong>Generate</strong> builds INSTALL [for load] → LOAD ×N → INSTALL [for install]. The file is only used to generate the APDUs — it is not stored, not even its name. On selection it is validated and its memory requirements are estimated (the NVRAM requirement as the code-image + persistent-data total, plus RAM); the required libraries, package identity and component breakdown are listed too (grouped under Package / Requires / Memory / Components headings). <strong>Generate</strong> stays disabled until the analysis succeeds.</li>
|
||||
<li><strong>Delete AID</strong> — AID list (one per line) and P2 (<em>object only</em> / <em>object and related objects</em>) → DELETE APDUs.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">The table lists each script with its kind, APDU count and creation time, plus <strong>Edit</strong> and <strong>Delete</strong>; the editor has a name field and the APDU textarea. Over a session the server serves one C-APDU per card POST and tracks execution: the card reports status in its next POST (<code class="font-mono text-sm">X-Admin-Script-Status</code>), a session that dies resends only the unexecuted APDUs (<code class="font-mono text-sm">X-Admin-Resume</code> continues, a fresh dialog restarts), and a completed script is closed with <code class="font-mono text-sm">204 No Content</code>. The Remote APDU tab's RAM/GP builder can feed a command chain straight into the run with <strong>Queue in SCP81</strong>.</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Card reader (pySim) tab</h2>
|
||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Simulator</strong> are separate top-level tabs.</p>
|
||||
|
||||
<h3 id="file-manager" class="text-lg font-medium mb-2">6.1 File manager</h3>
|
||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills pinned above the scrolling tree together with <strong>Probe all files</strong>; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI — in a bordered block labelled with the file’s symbolic name — above the content pane. When the FCI references its access rules through an <code class="font-mono text-sm">EF.ARR</code> record (security attribute <code class="font-mono text-sm">8B</code>), the rules of the referenced record are shown as indented lines under that row: they are resolved from the card’s <code class="font-mono text-sm">EF.ARR</code> copies — the MF copy (<code class="font-mono text-sm">2F06</code>), the DF.TELECOM/DF.GSM copies (<code class="font-mono text-sm">6F06</code>) and the ADF.USIM copy (<code class="font-mono text-sm">6F06</code>): read once per card session at equip, refreshed on reset/equip and after an <code class="font-mono text-sm">EF.ARR</code> write; a snapshot resolves the same references from its own captured copy. The copy of the file’s own DF wins, walking up to MF; a reference with no copy in that chain stays unresolved unless a single candidate exists.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Read raw / Read decoded</strong> — read the selected file as a hex dump or as a decoded field table (client-side EF decoders: IMSI, ICCID, SPN, PLMN lists, LOCI/PSLOCI/EPSLOCI, ADN/MSISDN, service tables, SUME, …); the server-side pySim JSON of the same read stays available in the collapsed <em>pySim JSON (server)</em> disclosure. The highlighted pill is the current view; clicking either pill (re-)reads the file. <strong>Read decoded</strong> is disabled when the client-side decoders do not cover the file (name/FID check, before reading).</li>
|
||||
<li><strong>Edit raw</strong> — edit the raw hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>); transparent files use a textarea, record files one input per record with selection checkboxes. The decoded view is read-only — <strong>Edit raw</strong> switches to the hex view first and reads the file if needed.</li>
|
||||
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||
<li><strong>ADM</strong> — files that need the administrator PIN fail with <code class="font-mono text-sm">6982</code>/<code class="font-mono text-sm">9804</code>; if the matching card preset (same ICCID) carries an ADM key, a <strong>Verify ADM</strong> button appears next to the error and the header badge (<code class="font-mono text-sm">ADM ✓/✗ ⚿</code>) becomes clickable. Every wrong key consumes an attempt: the remaining attempts are shown and a retry asks for confirmation. A blocked ADM cannot be recovered here — it needs the card’s unblock key.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Custom files</h3>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover. Persists in <code class="font-mono text-sm">localStorage</code>; JSON export/import.</p>
|
||||
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 pySim command line</h3>
|
||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">6.2 pySim command line</h3>
|
||||
<p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300 ms) and autocomplete.</p>
|
||||
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Raw APDU</h3>
|
||||
<h3 id="raw-apdu" class="text-lg font-medium mb-2">6.3 Raw APDU</h3>
|
||||
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
|
||||
|
||||
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Proactive UICC</h3>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p>
|
||||
|
||||
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald <strong>STK: <title></strong> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened.</p>
|
||||
|
||||
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
|
||||
|
||||
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Proactive command log</h4>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 PROVIDE LOCAL INFORMATION data dictionary</h4>
|
||||
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 §8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
|
||||
|
||||
<h4 id="status-polling" class="font-medium mb-1">5.5.5 STATUS polling</h4>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
|
||||
|
||||
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Profiler</h3>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Edit</strong>, <strong>Check</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. A filesystem rule is defined by:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), or <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes).</li>
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of”</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
||||
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3>
|
||||
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">6.4 Usage scenarios</h3>
|
||||
|
||||
<h4 id="scenario-a" class="font-medium mb-1">Scenario A — Working with files not in pySim’s model (Custom files)</h4>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).</li>
|
||||
<li>Open the <strong>Card reader</strong> tab → <strong>Custom files</strong> sub-tab.</li>
|
||||
<li>Enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Open the <strong>Profiler</strong> tab → <strong>Custom files</strong> sub-tab.</li>
|
||||
<li>Pick the root, type the parent DF path (a standard DF from the tree or a custom DF, any depth — suggestions appear while you type), the 4-hex FID and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
|
||||
<li>Click <strong>Add</strong> — the file appears in the tree in italics (unverified).</li>
|
||||
<li>Click the file to verify existence; on success (<code class="font-mono text-sm">9000</code>) it behaves like a normal file.</li>
|
||||
<li>Read, edit and save hex data; toggle Raw/Decoded views.</li>
|
||||
@@ -399,7 +434,7 @@
|
||||
<h4 id="scenario-b" class="font-medium mb-1">Scenario B — Simulating a real network environment for SIM testing</h4>
|
||||
<p class="text-sm mb-1"><strong>B.1 Answer PROVIDE LOCAL INFORMATION (PLI)</strong></p>
|
||||
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
|
||||
<li>Open <strong>Proactive UICC</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Open <strong>Simulator</strong> → <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
|
||||
<li>Use the decode/encode forms to set IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>), etc.</li>
|
||||
<li>Click <strong>Save</strong> — values persist server-side.</li>
|
||||
<li>Enable <strong>Polling</strong> (interval 30 s) so the card issues PLI periodically.</li>
|
||||
@@ -420,11 +455,143 @@
|
||||
</ul>
|
||||
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Proactive UICC, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Prerequisites</h3>
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Profiler</h2>
|
||||
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
|
||||
<h4 class="font-medium mb-1">Profile list</h4>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||
<li><strong>Profile from card <ICCID></strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor. Card-dependent buttons carry the equipped card's ICCID and are disabled while it is not readable.</li>
|
||||
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
|
||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||
<li>Each profile row shows its name and creation time, with <strong>Check card <ICCID></strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Clone</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions. <strong>Clone</strong> copies the profile under the name <em>Copy of <profile></em> (with a <code class="font-mono text-sm">(2)</code>, <code class="font-mono text-sm">(3)</code>… suffix when that name already exists) and opens the copy in the editor, so both the name and the contents can be adjusted before saving.</li>
|
||||
</ul>
|
||||
<h4 class="font-medium mb-1">Filesystem rules</h4>
|
||||
<p class="text-sm mb-2">Rules run sequentially. The editor shows the symbolic pySim name (when known) next to each rule’s path; use <strong>Add rule</strong> to append one and <strong>Save</strong> to keep the changes. A filesystem rule is defined by:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Path</strong> — starts with <code class="font-mono text-sm">MF</code> (e.g. <code class="font-mono text-sm">MF/7F10/6F3A</code>) or an ADF AID (e.g. <code class="font-mono text-sm">A0000000871002/6F07</code>).</li>
|
||||
<li><strong>FCP/FCI check</strong> — how much of the file control information to verify: <strong>Filetype only (FCP)</strong> (existence + file type), <strong>Filetype + size (FCP)</strong> (adds file size, or record length/count for record files), <strong>Exact FCI</strong> (adds a byte-for-byte comparison of the raw SELECT response — the FCP template <code class="font-mono text-sm">'62'</code> — catching FID/AID, life-cycle status, security-attribute and proprietary-parameter changes), or <strong>Custom (per data object)</strong> — a list of per-object expectations: each row names a data object (the standard tags with their names, the nested <code class="font-mono text-sm">A5/xx</code>/<code class="font-mono text-sm">C6/xx</code> children included, or a free tag), the mode — <em>value</em> (no <code class="font-mono text-sm">?</code> matches as a prefix, <code class="font-mono text-sm">?</code> masks a nibble: <code class="font-mono text-sm">8B</code> + <code class="font-mono text-sm">2F06</code> accepts any <code class="font-mono text-sm">EF.ARR</code> record of that FID, <code class="font-mono text-sm">2F06??</code> the 3-byte form), <em>present</em> (the object must exist) or <em>absent</em> (it must not) — and, for values, the hex to compare. Only the listed objects are checked; <strong>Extra data objects: ignore/fail</strong> additionally forbids anything unlisted (order-insensitive, unlike the byte-exact mode); listing a template accepts its children and listing a child accepts its parent, while an unlisted sibling still fails. Profiles created from a card or a snapshot with this mode are seeded with the captured FCI’s objects, and switching a rule to this mode seeds them from its captured FCI as well (the raw-FCI field belongs to the <strong>Exact FCI</strong> mode — this mode never compares the raw bytes); a rule with a captured FCI and an empty list can be refilled from it (<em>load from the captured FCI</em>), and a rule without one starts empty for manual rows.</li>
|
||||
<li><strong>Access rules</strong> — verifies a file’s access conditions as <em>decoded rules</em>, independent of which <code class="font-mono text-sm">EF.ARR</code> record the FCI’s security-attribute reference (<code class="font-mono text-sm">'8B'</code>) points at: the referenced record’s rules are resolved from the card’s (or the snapshot’s) <code class="font-mono text-sm">EF.ARR</code> and compared as a set with the captured lines. Captured automatically when a profile is created from a card or a snapshot for every file whose FCI carries a reference (EF.ARR’s own contents are skipped by default — the rules are the meaningful check); the editor shows the captured rules as structured rows — the operation as bit checkboxes (or the RESIZE FILE / INCREASE command form) and each security condition as a select (always, never, or a key reference with its usage), with a preview line of the canonical decoded string the check compares; <em>+ rule</em> builds a new one (READ always), and the template forms outside this subset stay raw and removable. The <strong>Match</strong> selector picks the comparison: <strong>Exact</strong> (default) requires the card’s rules to equal the listed lines; <strong>Contains</strong> requires every listed rule to be granted by the card, matched <em>per operation</em> (each AM bit / command entry, so a different AM grouping of the same policy still matches), and a rule not listed is not checked. The None/Check toggle disables the check without losing the rules; the block collapses to a header with the rule count and None hides it entirely. A file whose FCI lost the reference fails; a reference that cannot be resolved (no cached <code class="font-mono text-sm">EF.ARR</code>, record out of range) is reported as unverifiable. With <strong>Exact FCI</strong>, the optional <em>Ignore the EF.ARR reference in the exact FCI</em> checkbox compares the FCI without the <code class="font-mono text-sm">'8B'</code> value: a different record number no longer fails while every other DO is still compared byte-for-byte (the reference’s meaning is verified by this check).</li>
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; content mismatches additionally show, for files with a decoder, a decoded per-field comparison (only the differing fields, with expected/actual column headers above the side-by-side values; when the decoded values match, a note says so instead of the table, and a side whose record is all-FF shows <em>Empty (all FF)</em> in its cells), and FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). Access-rule mismatches list the decoded rule lines (✓ matching, − missing, + extra). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: <profile> → <card ICCID></em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same four modes above, default <strong>Filetype + size</strong>; choosing <strong>Custom (per data object)</strong> seeds every rule with the file’s captured data objects, ready to relax in the editor) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot’s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
|
||||
|
||||
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
||||
<p class="text-sm mb-2">The list view has three tabs — <strong>Profiles</strong>, <strong>Card snapshots</strong> and <strong>Custom files</strong> (see below). A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. Captured contents are shown with their decoded form where a decoder exists — a field table for transparent files and a one-line summary per record for record files. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>New snapshot: <ICCID></strong> — asks for a name and scans the card, then returns to the list.</li>
|
||||
<li><strong>Import snapshot</strong> — loads a snapshot from a JSON file.</li>
|
||||
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
|
||||
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: <profile> → <snapshot name></em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
|
||||
<li><strong>Compare snapshots</strong> compares two snapshots offline, like a profile check but <strong>always exact</strong> (no content masking) and <strong>byte-exact for the FCI and the EF.ARR contents</strong> — the semantic access-rules aspect belongs to profiles (the comparison’s exact FCI catches ARR record renumbering): pick the <em>master</em> snapshot and the <em>snapshot to check</em> and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: <master> → <checked></em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
|
||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover. A custom file always has a full path rooted at <strong>MF</strong> or an ADF (<strong>ADF.USIM</strong>, <strong>ADF.ISIM</strong>): pick the root, type the <strong>parent DF</strong> path (the root itself, a standard DF known from the file-manager tree, or a custom DF — any depth, with suggestions while you type; a parent that has not been seen in the tree yet stays valid and is marked <code class="font-mono text-sm">⚠</code>), the 4-hex <strong>FID</strong> and an alias (<code class="font-mono text-sm">EF.…</code>/<code class="font-mono text-sm">DF.…</code>; the prefix decides whether the entry is an EF or a DF). The canonical path removes the old ambiguity where the same file could be described both relatively and absolutely. Added files appear in the File manager tree; each row has <strong>Edit</strong> (path + alias; changing a DF’s path re-points its child entries — the button becomes <strong>Save</strong>, <strong>Cancel</strong> aborts) and <strong>Delete</strong> (deleting a DF also deletes its child entries after a confirmation). The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export to file</strong> / <strong>Import from file</strong>; legacy relative paths (e.g. <code class="font-mono text-sm">a153/4954</code>) are resolved on load, unresolvable ones are dropped and reported in the list.</p>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Simulator</h2>
|
||||
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has five pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands), <strong>eSIM LPA</strong> (local eUICC operations), <strong>BIP</strong> (the generic terminal side of BIP over TCP, see <a href="#bip" class="text-blue-600 dark:text-blue-400 hover:underline">§8.10</a>) and <strong>Test script</strong> (scripted card test scripts with proactive-command expectations, see <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">§8.9</a>).</p>
|
||||
|
||||
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3>
|
||||
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald button labelled with the menu title (<strong><title></strong>) that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead; a SET UP MENU without a title (the alpha identifier is optional) shows as <strong>Menu</strong>. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 §8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">→ SET UP MENU</code>) — what the card would do when the item is selected. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute. The overlay also answers the card’s <strong>GET INKEY</strong> and <strong>GET INPUT</strong> prompts (TS 102 223 §6.4.2/§6.4.3): the panel shows the card’s text with an input field whose constraints come from the command (Response length min–max, digits-only keyboard, hidden entry, default text prefilled, Yes/No buttons for GET INKEY, help when the card advertises it), and the answer is returned in the TERMINAL RESPONSE’s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).</p>
|
||||
|
||||
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Subscribed events (SET UP EVENT LIST)</h3>
|
||||
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, …) — one-click notification</li>
|
||||
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>); for Normal service the location information (MCC/MNC + LAC + Cell ID) can be included</li>
|
||||
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
|
||||
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes, plus the optional extended cause and extended information (CAG ID / NID / RedCap)</li>
|
||||
<li><strong>Call events</strong> — MT call (transaction identifier, calling party number + TON → Address, subaddress, IMS URI, media type; source <em>network</em>), Call connected and Call disconnected (transaction identifier, near/far-end source, media type, and a cause — absent, the zero-length radio-link-timeout form, or custom bytes), plus the optional IMS disconnection cause (SIP / Q.850 + cause)</li>
|
||||
<li><strong>Browser termination</strong> (user / error cause), <strong>(I-)WLAN access status</strong> (no coverage / coverage / connection on-going) and <strong>CSG cell selection</strong> (status + selection mechanism, access technology, CSG ID, HNB name, PLMN ID; source <em>network</em>)</li>
|
||||
<li><strong>Channel status</strong> (channel + link state + channel info, tag <code class="font-mono text-sm">B8</code>, with an optional bearer description <code class="font-mono text-sm">B5</code> and local address <code class="font-mono text-sm">BE</code>) and <strong>Data available</strong> (the same channel status plus the channel data length, tag <code class="font-mono text-sm">B7</code>), <strong>Display parameters changed</strong> (rows/columns + sizing/fonts/effects flags, tag <code class="font-mono text-sm">C0</code>), <strong>Network search mode change</strong> (manual/automatic, tag <code class="font-mono text-sm">E5</code>), <strong>Browsing status</strong> (browser error code, tag <code class="font-mono text-sm">E4</code>) and <strong>Frames information change</strong> (default frame + per-frame info, tag <code class="font-mono text-sm">E7</code>)</li>
|
||||
<li><strong>IMS registration</strong> (IMPU URIs + IMS status code) and <strong>Incoming IMS data</strong> (IARI) — source <em>network</em>; <strong>CAG cell selection</strong> (status + mechanism, access technology, CAG information list + HRNN list) and <strong>Slices status change</strong> (allowed / rejected / served / partially status, access technology, allowed / served / rejected S-NSSAI lists, with and without mapping, partial NSSAI) — source <em>network</em></li>
|
||||
<li><strong>Data Connection Status Change</strong> (0x1D) — status (successful / rejected / dropped-deactivated), connection type (PDP / PDN / PDU), optional (E/5G)SM cause, transaction identifier and location status, plus optional date-time, access technology, APN, PDP/PDN/PDU type and location information (MCC/MNC + LAC + Cell ID) — TS 131 111 §7.5.25</li>
|
||||
<li><strong>Poll Interval Negotiation</strong> (0x1C) — proposes a poll interval as a Duration (unit + value); after sending, the UICC's answer is shown (accepted / rejected / modified + duration), and a <em>modified</em> duration becomes the background poll interval (TS 102 223 §7.5.22). The form appears when the card subscribed to this event.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111. The form hides the parameters introduced after the <strong>effective CAT release</strong> — the equipped card preset's <code class="font-mono text-sm">CAT release</code> (matched by ICCID), or Rel-12 when unspecified — and a note under the form names the source and what was hidden (the release is set in the Cards tab). Each form also shows the exact <strong>event data (hex)</strong> it will send, updated as the fields change — copy it into a raw <em>Envelope</em> test-script step when an event has no semantic form. Slice information is sent in a single <code class="font-mono text-sm">ENVELOPE</code> (max 252 bytes of inner data) — chained (multi-envelope) delivery is not implemented, so the data must fit one envelope; an envelope carrying the partial NSSAI is marked as the Last Envelope automatically.</p>
|
||||
|
||||
<h3 id="proactive-log" class="text-lg font-medium mb-2">8.3 Proactive command log</h3>
|
||||
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Re-sending the TERMINAL PROFILE (<strong>Send</strong> or <strong>Rescue</strong>) starts a new CAT session but keeps the list: a boundary line marks it and the elapsed times restart (equipping another card clears the list). Expanding a row shows the decoded command: DISPLAY TEXT / SELECT ITEM / SET UP MENU text and items, BIP channel parameters, TIMER MANAGEMENT actions, PROVIDE LOCAL INFORMATION qualifier names (all standard qualifiers, including ESN 07, MEID 0B and Supported RATs 1A), and SEND SHORT MESSAGE — the SMS TPDU is parsed (type, TP-MR, TP-DA, TP-PID, TP-DCS, validity period, TP-UDL) with the user data shown as text for text codings (GSM 7-bit, UCS2, 8-bit) or as a secured packet (TS 31.115) when TP-PID = <code class="font-mono text-sm">7F</code> (SIM data download); UDH concatenation headers are decoded. Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
|
||||
|
||||
<h3 id="terminal-profile" class="text-lg font-medium mb-2">8.4 TERMINAL PROFILE</h3>
|
||||
<p class="text-sm mb-3">The <strong>TERMINAL PROFILE</strong> block (next to STATUS and Polling) offers <strong>Send</strong> (re-sends it, like Rescue) and <strong>Configure</strong>. The Configure dialog has a preset selector (device models, e.g. this project's BIP-capable handset profile), a hex field and a form with one checkbox per profile bit decoded per <strong>TS 102 223 §5.2</strong> / <strong>TS 31.111 §5.2</strong> (bytes 1–39, 3GPP-defined bits use the TS 31.111 names), each byte is a vertical list of its bits in spec order (<strong>b1 is the least significant bit and the first table row</strong>); bits that form a numeric value field per the spec (byte 11 soft keys, byte 13 BIP channels, byte 14/15 screen height/width, byte 16 menu width reduction, byte 19 TIA/EIA-136 version, byte 24 max frames) are shown as a single number input for the whole bit range, with the byte 11 <code class="font-mono text-sm">'FF'</code> reserved value noted in the hint; placed in fixed column groups (bytes 1-12 in 2 columns, 13-16 in 4, 17-18 in 2, 19-21 in 3, 22-25 in 2, 26-28 in 3, 29-30 in 2, later bytes one per row); toggling a bit updates the hex and editing the hex re-renders the form — the hex field is authoritative and unknown bytes/bits are preserved. <strong>Apply</strong> sends the new value to the server (and on to the card), resetting the STK session like Rescue; the change is in-memory only (the <code class="font-mono text-sm">--terminal-profile</code> CLI value is the startup default). The rules for inferring device characteristics from a TP (radio generation, headless modems, standards family) are collected in <code class="font-mono text-sm">docs/TERMINAL_PROFILE.md</code>.</p>
|
||||
|
||||
<h3 id="status-polling" class="text-lg font-medium mb-2">8.5 STATUS polling</h3>
|
||||
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). Background <strong>Polling</strong> is on by default (a CAT terminal polls during idle, TS 102 221 §14.6.2): after the idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1–255 s, default 30 s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. The toggle turns it off/on; the card's <strong>POLL INTERVAL</strong> command updates the interval (TS 102 223 §6.4.6; the TERMINAL RESPONSE reports the interval actually used), and after a <strong>POLLING OFF</strong> the toggle shows <strong>OFF</strong> in amber until a new POLL INTERVAL arrives — the manual <strong>Send STATUS</strong> button keeps working (presence detection and manual STATUS are not affected by POLLING OFF). Polling is disabled when the card is removed and re-enabled by the next equip.</p>
|
||||
|
||||
<h3 id="pli-dict" class="text-lg font-medium mb-2">8.6 TR Config — PROVIDE LOCAL INFORMATION response data</h3>
|
||||
<p class="text-sm mb-2">Editable hex values for all 25 PLI qualifiers (TS 102 223 §8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
|
||||
<li><strong>01</strong> IMEI · <strong>03</strong> Date/Time/TZ · <strong>04</strong> Language · <strong>05</strong> Timing Advance</li>
|
||||
<li><strong>06</strong> Access Technology · <strong>08</strong> IMEISV · <strong>09</strong> Search Mode</li>
|
||||
<li><strong>0A</strong> Battery · <strong>0E</strong> Multiple Access Technologies</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01, the test IMEI 490154203237518 and its IMEISV, language ‘en’, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
|
||||
|
||||
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
|
||||
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Cold boot</strong> — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.</li>
|
||||
<li><strong>EPS attach / 2G attach</strong> — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS). The attach is treated as a successful manual selection of the chosen PLMN: if it is listed in EF.FPLMN, its entries are cleared first (TS 23.122), so an attach never succeeds silently to a forbidden network.</li>
|
||||
<li><strong>Service lost / Limited service / Roaming denied</strong> — send the Location status event (only when the card subscribed to it), invalidate EPSNSC (optionally keeping the old KASME), dummy the location files (the LOCI/PSLOCI keep the PLMN, LAC <code class="font-mono text-sm">FFFE</code>, status <code class="font-mono text-sm">01</code>; EPSLOCI is wiped to <code class="font-mono text-sm">0B F6</code> + FF×13 + <code class="font-mono text-sm">FF FE 01</code>) and invalidate Kc. <strong>Roaming denied</strong> emulates a permanent “PLMN not allowed” rejection (NAS cause #11): the location files carry status <code class="font-mono text-sm">010</code> (EPSLOCI <code class="font-mono text-sm">0B F6</code> + FF×13 + <code class="font-mono text-sm">FF FE 02</code>), the denied VPLMN is appended to <strong>EF.FPLMN</strong> with the shift-list semantics of TS 31.102 §4.2.16 (never the home PLMN, never duplicated — an entry already listed is skipped) and the key context is dropped.</li>
|
||||
<li><strong>Churn</strong> — replay real → invalid EPSNSC records back-to-back (count and delay configurable).</li>
|
||||
<li><strong>SMS received</strong> — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.</li>
|
||||
<li><strong>CB reconfig</strong> — write the CBMI/CBMIR message-ID lists or clear them (all FF).</li>
|
||||
<li><strong>AUTHENTICATE</strong> — send AUTHENTICATE with the given or random RAND/AUTN and show the response. The command follows the card class: a UICC gets the 3G/EPS/5G form (<code class="font-mono text-sm">00 88 00 81 22</code>, response <code class="font-mono text-sm">DB</code> success or <code class="font-mono text-sm">DC</code> synchronisation failure with AUTS), a SIM the 2G form (<code class="font-mono text-sm">A0 88 00 00 10</code>, RAND only, response SRES + Kc).</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Parameters</strong> (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker and a <strong>Home network</strong> button that fills the card’s HPLMN from EF.HPLMNwAcT’s first record, falling back to the IMSI), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles (including <strong>Rejection: write FPLMN</strong>) and the churn count/delay. The step log lists every write, ENVELOPE and AUTHENTICATE with its SW. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent — FPLMN is appended only by the permanent rejection scenario (TS 31.102 §4.2.16, never duplicated) and an attach to a listed PLMN clears its entry first (successful manual selection, TS 23.122), and the 5GS location files are never written; the writes change the card and are visible to later snapshot comparisons.</p>
|
||||
|
||||
<h4 id="network-state" class="font-medium mb-1">Network state monitor</h4>
|
||||
<p class="text-sm mb-3">Next to the simulation buttons a compact <strong>Network state</strong> panel shows what the card currently holds and what was last simulated. Its header carries the <strong>simulated service state</strong> — <em>Undefined</em> until a scenario or a Location status event sets it, then <em>Normal service</em> (green), <em>Limited service</em> (amber) or <em>No service</em> (red), with a red <em>PLMN not allowed</em> marker when the location files or EF.FPLMN show a rejection — plus the current <strong>location</strong>: PLMN, country and operator (from the optional worldwide MCC/MNC list when loaded), the LAI/RAI/TAI, and the <strong>roaming class</strong> (<em>Home</em> when the PLMN equals the HPLMN, <em>Home equivalent</em> when it is in EF.EHPLMN, otherwise <em>Guest</em>). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (<code class="font-mono text-sm">init</code>, <code class="font-mono text-sm">write</code>, <code class="font-mono text-sm">read</code>, <code class="font-mono text-sm">refresh</code>); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a <code class="font-mono text-sm">… +N</code> counter, with the access technologies in the tooltip). The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event (multi-IMSI applets) and never polls the card — use <strong>Refresh</strong> to re-read all files on demand.</p>
|
||||
|
||||
<h3 id="esim" class="text-lg font-medium mb-2">8.8 eSIM / LPA (local operations)</h3>
|
||||
<p class="text-sm mb-2">For an eUICC (SGP.22/SGP.32) the <strong>eSIM</strong> pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact):</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Chip</strong> — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the <strong>rules authorisation table</strong> (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex. The fields are grouped into boxes — EID, EUICCInfo1, Addresses and the rules authorisation table on the left, the larger EUICCInfo2 in the right-hand box (a single column on narrow screens); short fields in the EUICCInfo2 box share two columns, long values span the full width.</li>
|
||||
<li><strong>Profiles</strong> — every profile with its state (enabled/disabled), nickname, provider, name, ICCID, ISD-P AID, class, owner and icon type; the card title shows the provider followed by the nickname (or the profile name). The icon image itself is shown at its natural size next to the metadata, and the <strong>Icon</strong> row carries its type and data size in bytes. <strong>Enable</strong>/<strong>Disable</strong> switches a profile; the card usually sends REFRESH first (logged in the proactive command log) — the terminal response completes the switch and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list.</li>
|
||||
<li><strong>Notifications</strong> — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">No profile downloads, no notification handling and no SM-DP+ interaction — only the local ES10a/b/c functions are used. The card must be an eUICC; otherwise the pill says so.</p>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
||||
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409), and background STATUS polling and the automatic proactive handling are suspended — a command the card announces stays pending for the script, and both resume when the run ends. The card’s own BIP commands (OPEN/SEND/RECEIVE/CLOSE CHANNEL, GET CHANNEL STATUS) are an exception: the terminal answers them for real — its channels keep working during the run and the BIP events flow for the channels the card itself opened (BIP <em>control</em> stays blocked) — unless the step’s <code class="font-mono text-sm">respond</code> overrides the answer with <code class="font-mono text-sm">raw</code> TLVs or a non-ok result; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
|
||||
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order — the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps — not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before → after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>Start with card reset</strong> resets the card and runs the full re-initialization before the suite starts (the scripts then run against a freshly initialized card); the preset must still match the card's ICCID afterwards and a required ADM is verified again for the new card session. The initialization consumes the card's initial proactive commands, so a script cannot catch the first command after the reset (they stay in the proactive log). <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start — a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created “Imported scripts” suite).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or <em>Event</em> (the semantic forms: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event — the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call → network) or its source field), <em>Menu Selection</em> (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE 80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — the server persists the consumed counter into it as the run proceeds), <em>STATUS</em> (with <em>attempts</em> > 1 it polls until the card announces a command) and <em>Proactive drain</em> (poll STATUS and consume every announced command — the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step — use it at the start, in the middle or at the end of a script to make sure the card has stopped sending; it also consumes a command a previous step left pending (a step that answered <code class="font-mono text-sm">91XX</code>). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards — <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
|
||||
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> means “no command”: the step fails at its fail level — Error stops the script, Warning continues (the tolerant “consume a command if there is one” form) — and a STATUS action with <em>attempts</em> > 1 polls when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> — distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data — for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first — the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all — leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> is part of the instruction: the <strong>result</strong> offers the full TS 102 223 §8.12.0 list (each entry labelled <code class="font-mono text-sm"><hex> - <alias or name></code>, plus a manual hex entry; with a command selected the list is grouped by the spec’s applicability table (the command-relevant values first, the rest after — nothing is hidden); the aliases follow the spec — <code class="font-mono text-sm">ok</code> 00, <code class="font-mono text-sm">modified</code> 07, <code class="font-mono text-sm">cancel</code> 10, <code class="font-mono text-sm">timeout</code> 12, <code class="font-mono text-sm">refused</code> 22, <code class="font-mono text-sm">not_understood</code> 32, …), the <strong>additional information</strong> byte of the general result (default <code class="font-mono text-sm">00</code>; the §8.12.2 terminal-problem values are suggested), the item identifier, the text (+DCS) and extra TLVs.</li>
|
||||
<li><strong>Fail levels</strong> — a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); every step failure honours it, including an expectation that finds no pending command and a card error raised during the step (an absent optional file's <code class="font-mono text-sm">6A82</code>); each check is reported with expected vs actual values.</li>
|
||||
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">Every step logs a semantic line to the server log under its step number (<code class="font-mono text-sm">TEST-RUN step N: …</code>), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, a proactive drain every STATUS/FETCH/command/TR round and the run cleanup the commands it drained (answered <code class="font-mono text-sm">ok</code> — a cancel is only sent when a script asks for it), and for a menu selection the complete ENVELOPE with the resolved item. Start the server with <code class="font-mono text-sm">--apdu-trace</code> to add the raw transport APDU log as well.</p>
|
||||
|
||||
<h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP terminal (generic TCP)</h3>
|
||||
<p class="text-sm mb-2">The <strong>BIP</strong> pill runs the terminal side of the Bearer Independent Protocol for cards that use TCP without HTTP OTA (SCP81): an applet or a push trigger can open a channel and exchange raw data. Only one BIP session runs at a time — starting here stops a running SCP81 listener, and starting the SCP81 listener stops this session (both report what was replaced).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Local sink (log only)</strong> (default) — accepts the card's channels on a local listener and only logs what arrives; nothing is ever sent back. Leave the port empty for an ephemeral port (the bound address appears in the status). Use it for cards that open a TCP connection just to upload data.</li>
|
||||
<li><strong>Card's destination (OPEN CHANNEL)</strong> — each channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).</li>
|
||||
<li><strong>Fixed target</strong> — every channel is connected to the configured Host:Port; the address the card requests is only logged.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">The status line shows the active mode, the bound address, the session owner and the open channels; the <strong>Channels</strong> box lists the per-channel byte counts and the <strong>BIP log</strong> records every open / send / receive / close event with a hex preview (the same events the SCP81 tab shows). CAT_TP over UDP is not implemented.</p>
|
||||
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">9. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Simulator, OTA delivery) require the local <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> — a small HTTP server bundled with SIMple that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
<h3 id="prerequisites" class="text-lg font-medium mb-2">9.1 Prerequisites</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Python 3.8+</strong> with <code class="font-mono text-sm">pip</code></li>
|
||||
<li><strong>Git</strong></li>
|
||||
@@ -432,20 +599,20 @@
|
||||
<li><strong>Windows only</strong> — use <strong>Python 3.10–3.13</strong> (3.13 recommended): <code class="font-mono text-sm">pyscard</code> (the PC/SC driver wrapper) ships precompiled wheels for these versions. On Python 3.9 / 3.14 pip builds <code class="font-mono text-sm">pyscard</code> from source, which requires Microsoft C++ Build Tools (“Desktop development with C++”). The SMPP bridge (<code class="font-mono text-sm">smpp.twisted3</code>) is intentionally not installed on Windows, so no C++ Build Tools are needed for Python 3.10–3.13.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Quick start — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">9.2 Quick start — Linux / macOS</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
chmod +x setup.sh start.sh
|
||||
./setup.sh # creates .venv, installs pysim + server (run once)
|
||||
./start.sh # starts the server (serves PWA + API, auto-detects reader)</pre>
|
||||
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Quick start — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
|
||||
cd otaman
|
||||
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">9.3 Quick start — Windows</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/simple.git
|
||||
cd simple
|
||||
setup.bat # creates .venv, installs pysim + server (run once)
|
||||
start.bat # starts the server (serves PWA + API)</pre>
|
||||
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Helper scripts</h3>
|
||||
<h3 id="helper-scripts" class="text-lg font-medium mb-2">9.4 Helper scripts</h3>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Script</th><th class="text-left py-1 px-2">Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -454,15 +621,16 @@ start.bat # starts the server (serves PWA + API)</pre>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Reader auto-detection (<code class="font-mono text-sm">start.sh</code>)</h3>
|
||||
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">9.5 Reader auto-detection</h3>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>PC/SC (Linux)</strong> — if the <code class="font-mono text-sm">pcscd</code> daemon is running, passes <code class="font-mono text-sm">-p 0</code></li>
|
||||
<li><strong>Serial (Linux)</strong> — if <code class="font-mono text-sm">/dev/ttyUSB0</code> exists, passes <code class="font-mono text-sm">-d /dev/ttyUSB0</code></li>
|
||||
<li><strong>PC/SC (Windows)</strong> — always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
|
||||
<li><strong>PC/SC (Linux)</strong> — <code class="font-mono text-sm">start.sh</code> passes <code class="font-mono text-sm">-p 0</code> when the <code class="font-mono text-sm">pcscd</code> daemon is running</li>
|
||||
<li><strong>PC/SC (Windows)</strong> — <code class="font-mono text-sm">start.bat</code> always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
|
||||
<li><strong>Server fallback</strong> — started without reader arguments, the server itself probes for a PC/SC reader at startup (3 attempts, 2 s apart)</li>
|
||||
<li><strong>Serial readers</strong> — start the server manually with <code class="font-mono text-sm">-d /dev/ttyUSB0</code> (Linux)</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3">If no reader is detected, the server starts without reader arguments and shows “Reader: none”. The card can be initialized later via the <strong>Equip</strong> button in the Card reader tab.</p>
|
||||
<p class="text-sm mb-3">If no card is present, the Card reader tab shows “No card detected”. Insert the card — the server auto-equips it (auto-equip is on by default) — or click <strong>Equip card</strong>. The server itself starts fine with an empty reader; the absence of a card is a normal state, not an error. Extra arguments given to <code class="font-mono text-sm">start.sh</code>/<code class="font-mono text-sm">start.bat</code> are passed through to the server (e.g. <code class="font-mono text-sm">./start.sh --gsmtap</code> to stream APDUs as GSMTAP-SIM packets for Wireshark / SIMtrace Analyser).</p>
|
||||
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Manual installation</h3>
|
||||
<h3 id="manual-install" class="text-lg font-medium mb-2">9.6 Manual installation</h3>
|
||||
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Linux/macOS
|
||||
@@ -471,28 +639,31 @@ source .venv/bin/activate # Linux/macOS
|
||||
# Install pysim
|
||||
pip install git+https://github.com/osmocom/pysim.git
|
||||
|
||||
# Install pysim-otaman-server (editable, so it serves the bundled PWA)
|
||||
# Install pysim-simple-server (editable, so it serves the bundled PWA)
|
||||
pip install -e .
|
||||
|
||||
# Start the server (serves PWA + API)
|
||||
pysim-otaman-server --http-port 8080</pre>
|
||||
pysim-simple-server --http-port 8080</pre>
|
||||
<p class="text-sm mb-3">Connect a PC/SC reader with a SIM card, then open <code class="font-mono text-sm">http://127.0.0.1:8080</code> — the PWA and API share one origin, so no CORS is involved.</p>
|
||||
<p class="text-sm mb-3">If the PWA is served from a public HTTPS host (e.g. <code class="font-mono text-sm">https://otaman.example.com</code>), two things are required to reach a local card server: (1) the server must answer the preflight with <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-otaman-server ≥ 1.6.1 does this automatically), and (2) the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to <code class="font-mono text-sm">127.0.0.1</code> is blocked before any preflight is sent.</p>
|
||||
<p class="text-sm mb-3">If the PWA is served from a public HTTPS host (e.g. <code class="font-mono text-sm">https://simple.example.com</code>), two things are required to reach a local card server: (1) the server must answer the preflight with <code class="font-mono text-sm">Access-Control-Allow-Private-Network: true</code> (pysim-simple-server ≥ 1.6.1 does this automatically), and (2) the browser must be allowed to access the local network — in Chrome/Edge/Vivaldi: Site settings → Local network access → allow the site (or accept the permission prompt). Without the browser permission, the request to <code class="font-mono text-sm">127.0.0.1</code> is blocked before any preflight is sent.</p>
|
||||
|
||||
|
||||
</section>
|
||||
<section class="mb-10">
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Version compatibility</h2>
|
||||
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">10. Version compatibility</h2>
|
||||
<table class="w-full text-sm mb-3 border-collapse">
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead>
|
||||
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (SIMple)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2">✅ Compatible</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">0.x.x</td><td class="py-1 px-2">❌ Outdated — update server</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">1.x.x</td><td class="py-1 px-2 font-mono">2.x.x+</td><td class="py-1 px-2">⚠️ Server newer — update PWA</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">any</td><td class="py-1 px-2 font-mono">same major</td><td class="py-1 px-2">✅ Compatible</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2 font-mono">any</td><td class="py-1 px-2 font-mono">older major</td><td class="py-1 px-2">❌ Outdated — update server</td></tr>
|
||||
<tr><td class="py-1 px-2 font-mono">any</td><td class="py-1 px-2 font-mono">newer major</td><td class="py-1 px-2">⚠️ Server newer — update PWA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm">The PWA checks the server version on connect via <code class="font-mono text-sm">GET /api/version</code> and warns if versions are incompatible.</p>
|
||||
<p class="text-sm">The PWA checks the server version on connect via <code class="font-mono text-sm">GET /api/version</code> and compares the major version (e.g. 3.x PWA with a 3.x server; a 2.x server is flagged as outdated).</p>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+18657
-1395
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "OTAMan",
|
||||
"short_name": "OTAMan",
|
||||
"description": "Offline APDU helper for SIM/USIM/RAM commands",
|
||||
"name": "SIMple",
|
||||
"short_name": "SIMple",
|
||||
"description": "UICC/SIM workbench: APDU toolkit, SIM OTA (SCP80/SCP81), STK/CAT simulator, card profiler",
|
||||
"start_url": "index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "#fafafa",
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "otaman",
|
||||
"name": "simple",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "otaman",
|
||||
"name": "simple",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"aes-js": "^3.1.2",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "otaman",
|
||||
"name": "simple",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, plus encoding conversions.",
|
||||
"description": "SIMple — a UICC workbench: APDU toolkit, SCP80/SCP81 OTA lab, STK/CAT (phone) simulator and card profiler.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify",
|
||||
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js && cat src/contrast.css >> style.css",
|
||||
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify && cat src/contrast.css >> style.css",
|
||||
"test": "node --test"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/anttro/otaman.git"
|
||||
"url": "https://github.com/anttro/simple.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"type": "commonjs",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ===== Theme contrast adjustments ===== */
|
||||
|
||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||
.text-gray-400 {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten softer (muted) text one step for higher contrast */
|
||||
.dark\:text-slate-500:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-slate-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: red text is too dark on dark backgrounds — lighten it */
|
||||
.dark :where(.text-red-500) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-600) {
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark :where(.text-red-700) {
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Light theme: darken light gray shades (backgrounds & borders) one step */
|
||||
.bg-gray-50 {
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-200 {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.border-gray-100 {
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.border-gray-300 {
|
||||
border-color: rgb(156 163 175 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.text-gray-300 {
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-100:hover {
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-200:hover {
|
||||
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.hover\:bg-gray-300:hover {
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: lighten gray fonts */
|
||||
.dark\:text-gray-400:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark\:text-gray-600:is(.dark *) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
|
||||
expand markers, ...) — match the lighten-on-dark level used above */
|
||||
.dark .text-gray-300:not([class*="dark:text-"]) {
|
||||
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-400:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-500:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-600:not([class*="dark:text-"]) {
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-700:not([class*="dark:text-"]) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.dark .text-gray-800:not([class*="dark:text-"]) {
|
||||
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
/* Dark theme: brighten gray borders one step so they stay visible on dark
|
||||
backgrounds */
|
||||
.dark\:border-slate-600:is(.dark *) {
|
||||
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
.dark\:border-slate-700\/50:is(.dark *) {
|
||||
border-color: rgb(71 85 105 / 0.5);
|
||||
}
|
||||
.dark\:border-slate-800:is(.dark *) {
|
||||
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
|
||||
muted gray level so the two remain distinguishable */
|
||||
.dark\:text-slate-300:is(.dark *) {
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
+697
-15
File diff suppressed because it is too large
Load Diff
+27
-5
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v60';
|
||||
const CACHE = 'simple-v458';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
@@ -14,11 +14,19 @@ const URLS = [
|
||||
'sim.svg',
|
||||
'sim_anim.svg',
|
||||
'nosim.svg',
|
||||
'uicc_files.json',
|
||||
];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
// Fetch the shell with `cache: 'reload'`: a plain addAll() would re-use
|
||||
// HTTP-cached copies, so a new SW version could precache a stale
|
||||
// style.css/index.html (the stale-style trap, v3.6.31).
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then(c => c.addAll(URLS))
|
||||
caches.open(CACHE).then(c =>
|
||||
Promise.all(URLS.map(u => fetch(u, { cache: 'reload' }).then(r => {
|
||||
if (r && r.ok) return c.put(u, r);
|
||||
})))
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
@@ -31,19 +39,33 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
const OFFLINE_RESPONSE = new Response('Offline: page not cached', {
|
||||
status: 503,
|
||||
statusText: 'Offline',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
if (!e.request.url.startsWith('http')) return;
|
||||
if (new URL(e.request.url).pathname.startsWith('/api/')) return; // live data, never cache
|
||||
const path = new URL(e.request.url).pathname;
|
||||
if (path.startsWith('/api/')) return; // live data, never cache
|
||||
if (e.request.method !== 'GET') return;
|
||||
const isNavigate = e.request.mode === 'navigate' || e.request.url.endsWith('sw.js');
|
||||
const isNavigate = e.request.mode === 'navigate';
|
||||
const isSwScript = path.endsWith('/sw.js');
|
||||
if (isNavigate) {
|
||||
e.respondWith(
|
||||
fetch(e.request).then(res => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request))
|
||||
}).catch(() =>
|
||||
caches.match(e.request)
|
||||
.then(r => r || caches.match('index.html'))
|
||||
.then(r => r || OFFLINE_RESPONSE)
|
||||
)
|
||||
);
|
||||
} else if (isSwScript) {
|
||||
e.respondWith(fetch(e.request).catch(() => OFFLINE_RESPONSE));
|
||||
} else {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r || fetch(e.request).then(res => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['pysimAdmRetryPrompt', 'pysimAdmSecuritySw']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
test('pysimAdmRetryPrompt is silent on the first try', () => {
|
||||
assert.strictEqual(pysimAdmRetryPrompt(null), null);
|
||||
assert.strictEqual(pysimAdmRetryPrompt(undefined), null);
|
||||
});
|
||||
|
||||
test('pysimAdmRetryPrompt warns with the remaining attempts', () => {
|
||||
const msg = pysimAdmRetryPrompt(3);
|
||||
assert.ok(msg.includes('3'), msg);
|
||||
assert.ok(msg.includes('attempt(s) left'), msg);
|
||||
assert.ok(msg.includes('block'), msg);
|
||||
assert.ok(msg.includes('Try again?'), msg);
|
||||
});
|
||||
|
||||
test('pysimAdmRetryPrompt uses the stronger last-attempt text', () => {
|
||||
for (const left of [1, 0]) {
|
||||
const msg = pysimAdmRetryPrompt(left);
|
||||
assert.ok(msg.includes('last attempt'), left + ': ' + msg);
|
||||
assert.ok(msg.includes('Try again?'), msg);
|
||||
}
|
||||
});
|
||||
|
||||
test('pysimAdmSecuritySw flags only the access-condition SWs', () => {
|
||||
for (const sw of ['6982', '9804']) assert.ok(pysimAdmSecuritySw(sw), sw);
|
||||
for (const sw of ['9000', '6985', '63C2', '', null, undefined]) {
|
||||
assert.ok(!pysimAdmSecuritySw(sw), String(sw));
|
||||
}
|
||||
});
|
||||
|
||||
test('file-manager security failures route through the ADM hint', () => {
|
||||
// no raw SW error rendering is left (all three sites use the hint helper)
|
||||
assert.ok(!/statusEl\.textContent = 'SW: ' \+ \(data\.sw/.test(html), 'raw SW error rendering is gone');
|
||||
assert.strictEqual((html.match(/pysimFsShowError\(statusEl, data\.sw/g) || []).length, 3);
|
||||
assert.ok(html.includes("btn.textContent = t('Verify ADM')"));
|
||||
assert.ok(html.includes("el.setAttribute('onclick', 'pysimVerifyAdm()')"));
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the tables leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const JC_AID_NAMES = {', 'const JC_AID_RIDS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const JC_AID_RIDS = {', 'function jcAidName').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'jcAidNorm'));
|
||||
eval(extractFunc(html, 'jcAidName'));
|
||||
eval(extractFunc(html, 'jcAidSuffix'));
|
||||
eval(extractFunc(html, 'jcAidFamily'));
|
||||
eval(extractFunc(html, 'jcAidHtml'));
|
||||
globalThis.esc = s => s;
|
||||
|
||||
test('the table resolves each standard library family', () => {
|
||||
// Oracle / JavaCard, ETSI (SIM and UICC era), 3GPP, GlobalPlatform
|
||||
assert.strictEqual(jcAidName('A0000000620101'), 'javacard.framework');
|
||||
assert.strictEqual(jcAidName('A000000062010101'), 'javacard.framework.service');
|
||||
assert.strictEqual(jcAidName('A0000000090003FFFFFFFF8910710002'), 'sim.toolkit');
|
||||
assert.strictEqual(jcAidName('A0000000090005FFFFFFFF8912000000'), 'uicc.toolkit');
|
||||
assert.strictEqual(jcAidName('A0000000871005FFFFFFFF8913200000'), 'uicc.usim.toolkit');
|
||||
assert.strictEqual(jcAidName('A00000015100'), 'org.globalplatform');
|
||||
// JAVACARD.md section 5: recognisable, not linkable libraries
|
||||
assert.strictEqual(jcAidName('A00000006203010F'), 'com.sun.javacard.crypto');
|
||||
assert.strictEqual(jcAidName('A0000000030000'), 'visa/openplatform');
|
||||
assert.strictEqual(jcAidName('123456654011'), 'com.denis');
|
||||
});
|
||||
|
||||
test('the default ISD resolves and unknown AIDs get a RID hint only', () => {
|
||||
assert.strictEqual(jcAidName('A000000151000000'), 'GlobalPlatform Issuer Security Domain');
|
||||
assert.strictEqual(jcAidName('A0000001515350'), 'GlobalPlatform RID');
|
||||
assert.strictEqual(jcAidName('A0000000871002FFFFFFFF8907090000'), '3GPP RID');
|
||||
// no pinned name for the legacy ISD AID, and vendor AIDs stay unnamed
|
||||
assert.strictEqual(jcAidName('A000000003000000'), '');
|
||||
assert.strictEqual(jcAidName('D276000005AAFFCAFE0010'), '');
|
||||
assert.strictEqual(jcAidName('A1130001180001'), '');
|
||||
});
|
||||
|
||||
test('the resolver normalizes case and spacing and rejects malformed input', () => {
|
||||
assert.strictEqual(jcAidName('a0 00 00 00 62 01 01'), 'javacard.framework');
|
||||
assert.strictEqual(jcAidName('a0000000620101'), 'javacard.framework');
|
||||
assert.strictEqual(jcAidName(''), '');
|
||||
assert.strictEqual(jcAidName(null), '');
|
||||
assert.strictEqual(jcAidName(undefined), '');
|
||||
assert.strictEqual(jcAidName('AB'), '');
|
||||
assert.strictEqual(jcAidName('A000000062010'), ''); // odd hex length
|
||||
});
|
||||
|
||||
test('the plain-text and HTML suffixes name known AIDs only', () => {
|
||||
assert.strictEqual(jcAidSuffix('A0000000620101'), ' (javacard.framework)');
|
||||
assert.strictEqual(jcAidSuffix('A0000001515350'), ' (GlobalPlatform RID)');
|
||||
assert.strictEqual(jcAidSuffix('D276000005AAFFCAFE0010'), '');
|
||||
globalThis.esc = s => 'E(' + s + ')';
|
||||
assert.strictEqual(jcAidHtml('A0000000620101'),
|
||||
'E(A0000000620101) <span class="text-gray-400 dark:text-slate-500">(E(javacard.framework))</span>');
|
||||
assert.strictEqual(jcAidHtml('D276000005AAFFCAFE0010'), 'E(D276000005AAFFCAFE0010)');
|
||||
globalThis.esc = s => s;
|
||||
});
|
||||
|
||||
test('the AID family follows the RID groups (JAVACARD.md section 6)', () => {
|
||||
assert.strictEqual(jcAidFamily('A0000000620101'), 'Oracle JavaCard API');
|
||||
assert.strictEqual(jcAidFamily('A0000000090003FFFFFFFF8910710002'), 'ETSI SIM (2G) API');
|
||||
assert.strictEqual(jcAidFamily('A0000000090005FFFFFFFF8912000000'), 'ETSI UICC API');
|
||||
assert.strictEqual(jcAidFamily('A0000000871005FFFFFFFF8913200000'), '3GPP USIM/ISIM API');
|
||||
assert.strictEqual(jcAidFamily('A00000015100'), 'GlobalPlatform API');
|
||||
// vendor/applet AIDs stay unlabelled
|
||||
assert.strictEqual(jcAidFamily('D276000005AAFFCAFE0010'), '');
|
||||
assert.strictEqual(jcAidFamily(''), '');
|
||||
});
|
||||
|
||||
test('jcAidNorm validates and normalises', () => {
|
||||
assert.strictEqual(jcAidNorm('a0 00 00 00 62 01 01'), 'A0000000620101');
|
||||
assert.strictEqual(jcAidNorm('AB'), '');
|
||||
assert.strictEqual(jcAidNorm('A000000062010'), ''); // odd hex length
|
||||
assert.strictEqual(jcAidNorm(null), '');
|
||||
});
|
||||
|
||||
test('the package table is well-formed', () => {
|
||||
const keys = Object.keys(JC_AID_NAMES);
|
||||
assert.ok(keys.length >= 45, 'entries: ' + keys.length);
|
||||
keys.forEach(k => {
|
||||
assert.match(k, /^[0-9A-F]{10,32}$/, k);
|
||||
assert.strictEqual(k.length % 2, 0, k);
|
||||
assert.ok(typeof JC_AID_NAMES[k] === 'string' && JC_AID_NAMES[k].length > 0, k);
|
||||
});
|
||||
Object.keys(JC_AID_RIDS).forEach(k => assert.match(k, /^[0-9A-F]{10}$/, k));
|
||||
});
|
||||
@@ -50,7 +50,9 @@ for (const c of consts) {
|
||||
}
|
||||
const start = html.indexOf('// ===== C-APDU Parser =====');
|
||||
const end = html.indexOf('// ===== Response Parser =====');
|
||||
eval(prefix + html.slice(start, end).replace(/^const PARSE_INS = /m, 'var PARSE_INS = '));
|
||||
eval(prefix + html.slice(start, end)
|
||||
.replace(/^const PARSE_INS = /m, 'var PARSE_INS = ')
|
||||
.replace(/^const PRIVILEGE_NAMES = /m, 'var PRIVILEGE_NAMES = '));
|
||||
|
||||
test('Compact RAM INSTALL [for install] UICC', () => {
|
||||
const tree = parseHexTree('80E60C00214F08A000000151000000C70100EA128010000000020101020200011603B0000100');
|
||||
@@ -165,6 +167,39 @@ test('parseTlvList handles BER-TLVs', () => {
|
||||
assert.strictEqual(tlvs[1].tag, '82');
|
||||
});
|
||||
|
||||
test('parseBerLen handles short and long form lengths (ISO 7816-4 5.2)', () => {
|
||||
assert.deepStrictEqual(parseBerLen('1200', 0), { len: 18, consumed: 2 });
|
||||
assert.deepStrictEqual(parseBerLen('8112', 0), { len: 18, consumed: 4 });
|
||||
assert.deepStrictEqual(parseBerLen('820100', 0), { len: 256, consumed: 6 });
|
||||
assert.deepStrictEqual(parseBerLen('820182' + '0102030405060708', 0), { len: 386, consumed: 6 });
|
||||
});
|
||||
|
||||
test('parseTlvList parses long-form lengths (81/82)', () => {
|
||||
const short = parseTlvList('6212' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(short.length, 1);
|
||||
assert.strictEqual(short[0].tag, '62');
|
||||
assert.strictEqual(short[0].length, 18);
|
||||
|
||||
// same content with a long-form outer length
|
||||
const long81 = parseTlvList('628112' + '8202412183026F078A010580020009880110');
|
||||
assert.strictEqual(long81.length, 1);
|
||||
assert.strictEqual(long81[0].length, 18);
|
||||
assert.strictEqual(long81[0].value, short[0].value);
|
||||
|
||||
// 2-byte length form
|
||||
const long82 = parseTlvList('62820004' + '80020009');
|
||||
assert.strictEqual(long82.length, 1);
|
||||
assert.strictEqual(long82[0].length, 4);
|
||||
assert.strictEqual(long82[0].value, '80020009');
|
||||
|
||||
// inner TLV with a long-form length (A5 81 05 85 03 00 00 00)
|
||||
const inner = parseTlvList('A581058503000000');
|
||||
assert.strictEqual(inner.length, 1);
|
||||
assert.strictEqual(inner[0].tag, 'A5');
|
||||
assert.strictEqual(inner[0].length, 5);
|
||||
assert.strictEqual(inner[0].value, '8503000000');
|
||||
});
|
||||
|
||||
test('gsm7Decode unpacks "HI" from C824', () => {
|
||||
const bytes = new Uint8Array([0xC8, 0x24]);
|
||||
assert.strictEqual(gsm7Decode(bytes), 'HI');
|
||||
@@ -194,10 +229,26 @@ test('decodeTextData explicit DCS 08 with malformed payload returns ?', () => {
|
||||
assert.strictEqual(decodeTextData('0841'), '?');
|
||||
});
|
||||
|
||||
test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => {
|
||||
assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation');
|
||||
});
|
||||
|
||||
test('decodePrivileges byte 2 b6 is Token Verification per GPC v2.3 Table 11-8', () => {
|
||||
assert.ok(decodePrivileges('0020').includes('Token Verification'));
|
||||
});
|
||||
|
||||
test('decodePrivileges byte 1: DAP Verification vs Mandated DAP (GP Table 11-7)', () => {
|
||||
assert.strictEqual(decodePrivileges('80'), 'Security Domain');
|
||||
assert.strictEqual(decodePrivileges('C0'), 'Security Domain, DAP Verification');
|
||||
assert.strictEqual(decodePrivileges('C1'), 'Security Domain, Mandated DAP Verification');
|
||||
assert.strictEqual(decodePrivileges('40'), 'None'); // b7 alone is no privilege
|
||||
assert.strictEqual(decodePrivileges('01'), 'None'); // b1 alone is only the flag
|
||||
});
|
||||
|
||||
test('decodePrivileges', () => {
|
||||
assert.ok(decodePrivileges('00').includes('None'));
|
||||
assert.ok(decodePrivileges('80').includes('Security Domain'));
|
||||
|
||||
@@ -103,7 +103,7 @@ function buildPcHtml(extraFields) {
|
||||
}
|
||||
|
||||
const FNS = ['berLenStr', 'gsm7TextToSeptets', 'gsm7Encode', 'genBerPcValue', 'genErrorActionValue',
|
||||
'genScriptChainingValue'];
|
||||
'genScriptChainingValue', 'chainKind', 'chainIsEmbedded', 'chainCommands', 'berCmdChainId', 'genBerCmdApdu', 'genBerRowValue'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
const berTagsMatch = html.match(/const BER_TAGS = \{[^}]*\};/);
|
||||
@@ -116,6 +116,16 @@ for (const m of [berTagsMatch, berQualMatch, berDevMatch, berTonesMatch, gsm7Alp
|
||||
if (!m) throw new Error('constant not found');
|
||||
code = m[0] + '\n' + code;
|
||||
}
|
||||
for (const c of ['CHAIN_CMDS_SIM', 'CHAIN_CMDS_USIM', 'CHAIN_CMDS_RAM']) {
|
||||
const m = html.match(new RegExp('const ' + c + ' = \\[[\\s\\S]*?\\n\\];'));
|
||||
if (!m) throw new Error('catalog not found: ' + c);
|
||||
code += m[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += html.match(/const _chains = \{\};/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const CHAIN_LISTENERS = \{\};/)[0].replace('const ', 'var ') + '\n';
|
||||
// The embedded command chain is built by the shared command builders, which
|
||||
// are covered by ts102226.test.js; here only the C-APDU row wiring matters.
|
||||
code += 'function chainBuildRowHex(chainId) { return chainId === \'ber-ram-7\' ? \'80D0010101AA\' : \'\'; }\n';
|
||||
code += '\nvar __berConsts = {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP};';
|
||||
eval(code);
|
||||
const {BER_TAGS, BER_QUAL, BER_DEVICES, BER_TONES, GSM7_ALPHABET, GSM7_EXT_MAP} = __berConsts;
|
||||
@@ -277,4 +287,47 @@ test('genScriptChainingValue no position emits empty', () => {
|
||||
keep.name = 'chaining-0-keep';
|
||||
row.children.push(first, interm, last, keep, el('chaining-script-id'), el('chaining-additional'));
|
||||
assert.strictEqual(genScriptChainingValue(row, '83'), '');
|
||||
});
|
||||
|
||||
// ===== Expanded Script C-APDU rows (TS 102 226 5.2.1.0/5.2.1.1) =====
|
||||
|
||||
function cmdRow(mode, value) {
|
||||
const row = new StubEl();
|
||||
row.dataset = { berUid: '7' };
|
||||
const modeEl = el('ber-cmd-mode', mode);
|
||||
const hexEl = el('ber-hex', value || '');
|
||||
const typeEl = el('ber-type', 'c-apdu');
|
||||
row.children.push(typeEl, modeEl, hexEl);
|
||||
row.querySelector = sel => ({ '.ber-type': typeEl, '.ber-cmd-mode': modeEl, '.ber-hex': hexEl })[sel] || null;
|
||||
return row;
|
||||
}
|
||||
|
||||
test('genBerCmdApdu reads pasted hex or the embedded command chain', () => {
|
||||
assert.strictEqual(genBerCmdApdu(cmdRow('hex', '80 ca ff21 00')), '80CAFF2100');
|
||||
assert.strictEqual(genBerCmdApdu(cmdRow('ram', 'ignored')), '80D0010101AA');
|
||||
assert.strictEqual(genBerCmdApdu(cmdRow('hex', '')), '');
|
||||
});
|
||||
|
||||
test('genBerRowValue wraps a C-APDU row in the 22 command TLV', () => {
|
||||
assert.strictEqual(genBerRowValue(cmdRow('ram', '')), '220680D0010101AA');
|
||||
assert.strictEqual(genBerRowValue(cmdRow('hex', '80C AFF2100')), '220580CAFF2100');
|
||||
assert.strictEqual(genBerRowValue(cmdRow('hex', '')), '');
|
||||
});
|
||||
|
||||
test('berLenStr uses the BER long form for 2- and 3-byte lengths', () => {
|
||||
assert.strictEqual(berLenStr(0), '00');
|
||||
assert.strictEqual(berLenStr(127), '7F');
|
||||
assert.strictEqual(berLenStr(128), '8180');
|
||||
assert.strictEqual(berLenStr(255), '81FF');
|
||||
assert.strictEqual(berLenStr(256), '820100');
|
||||
assert.strictEqual(berLenStr(65535), '82FFFF');
|
||||
assert.strictEqual(berLenStr(65536), '83010000');
|
||||
});
|
||||
|
||||
test('the embedded pickers offer the RFM/RAM command sets without GET RESPONSE', () => {
|
||||
assert.ok(chainCommands('ber-sim-1').some(c => c.value === 'select'));
|
||||
assert.ok(chainCommands('ber-usim-1').some(c => c.value === 'create-file'));
|
||||
assert.ok(chainCommands('ber-ram-1').some(c => c.value === 'push'));
|
||||
assert.ok(!chainCommands('ber-ram-1').some(c => c.value === 'get-response'));
|
||||
assert.ok(chainCommands('chain-ram').some(c => c.value === 'get-response'));
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
globalThis.esc = s => s;
|
||||
globalThis.t = s => s;
|
||||
for (const fn of ['bipReplacedNotice', 'bipChannelLine', 'bipStatusLine', 'bipStartBody', 'bipModeChanged']) {
|
||||
eval(extractFunc(html, fn));
|
||||
}
|
||||
|
||||
function fakeDom(mode) {
|
||||
const els = {};
|
||||
for (const id of ['bip-mode', 'bip-host', 'bip-port', 'bip-sink-note',
|
||||
'bip-passthru-note', 'bip-redirect-note']) {
|
||||
const el = {
|
||||
disabled: false, placeholder: '', toggled: {},
|
||||
classList: { toggle(c, on) { el.toggled[c] = on; } },
|
||||
};
|
||||
els[id] = el;
|
||||
}
|
||||
els['bip-mode'].value = mode;
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
test('bipStartBody builds the control body per mode', () => {
|
||||
// passthru: no host/port at all - the card chooses the destination
|
||||
assert.deepStrictEqual(bipStartBody('passthru', '127.0.0.1', '9'),
|
||||
{ body: { action: 'start', mode: 'passthru' } });
|
||||
// sink: host optional, empty port = server-side ephemeral
|
||||
assert.deepStrictEqual(bipStartBody('sink', '127.0.0.1', ''),
|
||||
{ body: { action: 'start', mode: 'sink', host: '127.0.0.1' } });
|
||||
assert.deepStrictEqual(bipStartBody('sink', '', '9101'),
|
||||
{ body: { action: 'start', mode: 'sink', port: 9101 } });
|
||||
// fixed target: host and a real port required
|
||||
assert.deepStrictEqual(bipStartBody('redirect', '10.0.0.5', '8080'),
|
||||
{ body: { action: 'start', mode: 'redirect', host: '10.0.0.5', port: 8080 } });
|
||||
assert.strictEqual(bipStartBody('redirect', '', '8080').error, 'Fixed target requires the host');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', '').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', '0').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', 'x').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('sink', '127.0.0.1', 'x').error, 'Port must be 0-65535');
|
||||
assert.strictEqual(bipStartBody('sink', '127.0.0.1', '70000').error, 'Port must be 0-65535');
|
||||
});
|
||||
|
||||
test('bipReplacedNotice names what the start replaced', () => {
|
||||
assert.strictEqual(bipReplacedNotice(null), '');
|
||||
assert.strictEqual(bipReplacedNotice({}), '');
|
||||
assert.strictEqual(bipReplacedNotice({ owner: 'scp81', mode: 'tls' }),
|
||||
'Stopped the SCP81 listener (tls)');
|
||||
assert.strictEqual(bipReplacedNotice({ owner: 'bip', mode: 'sink' }),
|
||||
'Stopped the BIP session (sink)');
|
||||
});
|
||||
|
||||
test('bipChannelLine summarizes one channel', () => {
|
||||
assert.strictEqual(bipChannelLine({ id: 1, requested: '10.0.0.5:80',
|
||||
target: '10.0.0.5:80', bytes_in: 412, bytes_out: 0, pending: 3,
|
||||
peer_closed: false }),
|
||||
'ch1 10.0.0.5:80 -> 10.0.0.5:80 in:412 out:0 buffer:3 open');
|
||||
assert.strictEqual(bipChannelLine({ id: 2, peer_closed: true }),
|
||||
'ch2 ? -> ? in:0 out:0 buffer:0 peer closed');
|
||||
});
|
||||
|
||||
test('bipStatusLine shows the mode, bound address, connections, owner and channels', () => {
|
||||
assert.strictEqual(bipStatusLine({ owner: 'bip', bip: { channels: [] },
|
||||
listener: { mode: 'sink', host: '127.0.0.1', port: 53121, connections: 1 } }),
|
||||
'sink 127.0.0.1:53121 | 1 connections \u00b7 owner: bip');
|
||||
assert.strictEqual(bipStatusLine({ owner: 'bip', bip: { channels: [] },
|
||||
listener: { mode: 'passthru' } }), 'passthru \u00b7 owner: bip');
|
||||
assert.strictEqual(bipStatusLine(null), '');
|
||||
const withChannel = bipStatusLine({
|
||||
owner: 'bip',
|
||||
bip: { channels: [
|
||||
{ id: 1, requested: 'a:1', target: 'b:2', bytes_in: 5, bytes_out: 7, pending: 0 }] },
|
||||
listener: { mode: 'sink', host: 'h', port: 1 },
|
||||
});
|
||||
assert.ok(withChannel.includes('ch1 a:1 -> b:2 in:5 out:7 buffer:0 open'), withChannel);
|
||||
});
|
||||
|
||||
test('bipModeChanged toggles the fields and the mode notes', () => {
|
||||
let els = fakeDom('sink');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].placeholder, 'ephemeral');
|
||||
assert.strictEqual(els['bip-sink-note'].toggled.hidden, false);
|
||||
assert.strictEqual(els['bip-passthru-note'].toggled.hidden, true);
|
||||
assert.strictEqual(els['bip-redirect-note'].toggled.hidden, true);
|
||||
|
||||
els = fakeDom('passthru');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, true);
|
||||
assert.strictEqual(els['bip-port'].disabled, true);
|
||||
assert.strictEqual(els['bip-host'].toggled['opacity-40'], true);
|
||||
assert.strictEqual(els['bip-passthru-note'].toggled.hidden, false);
|
||||
assert.strictEqual(els['bip-sink-note'].toggled.hidden, true);
|
||||
|
||||
els = fakeDom('redirect');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].placeholder, '8443');
|
||||
assert.strictEqual(els['bip-redirect-note'].toggled.hidden, false);
|
||||
});
|
||||
|
||||
test('the BIP pill, view and API endpoints are wired', () => {
|
||||
assert.match(html, /data-phone-sub="bip"[^>]*data-l10n="BIP"/);
|
||||
assert.match(html, /id="phone-sub-bip"/);
|
||||
assert.match(html, /\['phone', 'tr', 'bip', 'esim', 'test'\]/);
|
||||
assert.match(html, /else if \(name === 'bip'\) \{\s*bipEnter\(\);/);
|
||||
assert.match(html, /bip: 'bip'/);
|
||||
// the poll stops when the Simulator tab is left
|
||||
assert.match(html, /if \(_bipTimer && name !== 'phone'\)/);
|
||||
for (const id of ['bip-mode', 'bip-host', 'bip-port', 'bip-start-btn',
|
||||
'bip-stop-btn', 'bip-state', 'bip-channels', 'bip-log']) {
|
||||
assert.ok(html.includes('id="' + id + '"'), id);
|
||||
}
|
||||
assert.match(html, /\/api\/bip\/control/);
|
||||
assert.match(html, /\/api\/bip\/status/);
|
||||
assert.match(html, /\/api\/bip\/log-clear/);
|
||||
assert.match(html, /\/api\/bip\/log'/);
|
||||
// the shared one-session rule and the owner marker are both shown
|
||||
assert.match(html, /One BIP session at a time: starting here replaces a running SCP81 listener/);
|
||||
assert.match(html, /if \(st\.owner === 'bip'\) s = t\('BIP session:'\)/);
|
||||
assert.match(html, /if \(e\.peer\) parts\.push\(e\.peer\);/);
|
||||
assert.match(html, /if \(isViewVisible\('phone-sub-bip'\)\) \{ bipStatusRefresh\(\); bipLogRefresh\(\); \}/);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Rewrite the top-level const so it leaks out of sloppy-mode eval.
|
||||
eval(extractBlock('const _capAnalysis = {', 'function capFileKey').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'capFileKey'));
|
||||
eval(extractFunc(html, 'capGateOk'));
|
||||
eval(extractBlock('const JC_AID_NAMES = {', 'const JC_AID_RIDS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const JC_AID_RIDS = {', '// Normalise an AID').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const JC_FRAMEWORK_SDK = {', '// ===== TLV parsers for GET STATUS').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'jcAidNorm'));
|
||||
eval(extractFunc(html, 'jcAidName'));
|
||||
eval(extractFunc(html, 'jcAidFamily'));
|
||||
eval(extractFunc(html, 'capMemBytes'));
|
||||
eval(extractFunc(html, 'capQuotaText'));
|
||||
eval(extractFunc(html, 'capMemHtml'));
|
||||
eval(extractFunc(html, 'capAnalyzeFile', true));
|
||||
// The RAM CAP analysis also applies the detected toolkit mode and prefills
|
||||
// the import-probe field (DOM-bound; their own logic is covered in
|
||||
// ram.test.js) - stub them for these state tests.
|
||||
globalThis.ramApplyCapToolkitMode = () => {};
|
||||
globalThis.ramProbeFill = () => {};
|
||||
|
||||
globalThis.esc = s => s;
|
||||
globalThis.t = s => s;
|
||||
globalThis.capRenderAnalysis = () => {};
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
|
||||
function resetState() {
|
||||
_capAnalysis.ram = { key: null, status: 'idle', memory: null, error: null, token: 0 };
|
||||
_capAnalysis.scripts = { key: null, status: 'idle', memory: null, error: null, token: 0 };
|
||||
}
|
||||
|
||||
function memFixture() {
|
||||
return {
|
||||
cap_version: '2.1',
|
||||
package_aid: 'A0000000620101',
|
||||
package_version: '1.0',
|
||||
package_name: null,
|
||||
flags: { raw: 4, int: false, export: false, applet: true },
|
||||
applets: ['A0000000620101'],
|
||||
imports: [{ aid: 'A0000000620101', minor: 0, major: 1, refs: 6 }],
|
||||
components: [{ name: 'Header', size: 20 }, { name: 'Method', size: 2800 },
|
||||
{ name: 'ConstantPool', size: 180 }],
|
||||
code: { method_component: 2048, load_file: 3000 },
|
||||
nvram: { static_image: 12, array_init: 4, install_objects: 100, header_overhead: 12, ref_storage: 8, total: 136, runtime: 0, requirement: 3136 },
|
||||
ram: { transient_arrays: 16, runtime_transient: 0, peak_frame: 8, total: 24 },
|
||||
suggested: { c6: 3000, c7: 272, c8: 136 },
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('capMemBytes formats bytes and kilobytes', () => {
|
||||
assert.strictEqual(capMemBytes(0), '0 B');
|
||||
assert.strictEqual(capMemBytes(1023), '1023 B');
|
||||
assert.strictEqual(capMemBytes(1024), '1.0 kB');
|
||||
assert.strictEqual(capMemBytes(1587), '1.5 kB');
|
||||
assert.strictEqual(capMemBytes(null), '0 B');
|
||||
});
|
||||
|
||||
test('capMemHtml renders the grouped report: package, requires, memory, components, warnings', () => {
|
||||
const mem = memFixture();
|
||||
mem.warnings = ['method class[0].token[1]: unknown opcode 0xAA, scan stopped'];
|
||||
const out = capMemHtml(mem);
|
||||
// the border legend titles the box now; the body starts with the sections
|
||||
assert.ok(!out.includes('CAP requirements (estimate)'), out);
|
||||
// grouped, labelled sections (always visible except components/notes)
|
||||
assert.ok(out.includes('>Package</div>'), out);
|
||||
assert.ok(out.includes('>Requires (1)</div>'), out);
|
||||
assert.ok(out.includes('>Memory</div>'), out);
|
||||
assert.ok(out.includes('>Components (3)'), out);
|
||||
// the static notes moved to the help; only scan warnings stay in the box
|
||||
assert.ok(!out.includes('>Notes'), out);
|
||||
assert.ok(!out.includes('Table 11-48'), out);
|
||||
assert.ok(out.includes('unknown opcode 0xAA'), out);
|
||||
// memory: the C6+C8-style total once, with indented children
|
||||
assert.ok(out.includes('NVRAM requirement'), out);
|
||||
assert.ok(out.includes('>3.1 kB</td>'), out);
|
||||
assert.ok(out.includes('code image + data'), out);
|
||||
assert.ok(out.includes('>2.9 kB</td>'), out);
|
||||
assert.ok(out.includes('Method.cap 2.0 kB'), out);
|
||||
assert.ok(out.includes('other components 952 B'), out);
|
||||
assert.ok(out.includes('>136 B</td>'), out);
|
||||
assert.ok(out.includes('static image 12 B'), out);
|
||||
assert.ok(out.includes('reference storage 8 B'), out);
|
||||
assert.ok(out.includes('>24 B</td>'), out);
|
||||
assert.ok(out.includes('peak method frame 8 B'), out);
|
||||
assert.ok(out.includes('C6=3000 B (0x0BB8) \u00b7 C7=272 B (0x0110) \u00b7 C8=136 B (0x0088)'), out);
|
||||
// requires: library name, AID, minimum version, family and refs
|
||||
const req = out.slice(out.indexOf('Requires (1)'), out.indexOf('Memory</div>'));
|
||||
assert.ok(req.includes('>AID</td>'), req);
|
||||
assert.ok(req.includes('>javacard.framework</td>'), req);
|
||||
assert.ok(req.includes('>A0000000620101</td>'), req);
|
||||
assert.ok(req.includes('>\u2265 1.0</td>'), req);
|
||||
assert.ok(req.includes('>Oracle JavaCard API</td>'), req);
|
||||
assert.ok(req.includes('>6</td>'), req);
|
||||
// package identity + platform requirement derived from the framework version
|
||||
assert.ok(out.includes('A0000000620101') && out.includes('>v1.0</td>'), out);
|
||||
assert.ok(out.includes('applet package'), out);
|
||||
assert.ok(out.includes('(javacard.framework)</span>'), out);
|
||||
assert.ok(out.includes('>Platform</td>'), out);
|
||||
assert.ok(out.includes('Java Card \u2265 2.1.1/2.1.2 \u00b7 CAP format 2.1'), out);
|
||||
// components in load-file order with sizes, shares and the total row
|
||||
assert.ok(out.includes('>Header</td>') && out.includes('>20 B</td>') && out.includes('>1%</td>'), out);
|
||||
assert.ok(out.includes('>Method</td>') && out.includes('>2.7 kB</td>') && out.includes('>93%</td>'), out);
|
||||
assert.ok(out.includes('>ConstantPool</td>') && out.includes('>180 B</td>') && out.includes('>6%</td>'), out);
|
||||
assert.ok(out.includes('Load file (total)') && out.includes('>100%</td>'), out);
|
||||
const c1 = out.indexOf('Header</td>'), c2 = out.indexOf('Method</td>'), c3 = out.indexOf('ConstantPool</td>');
|
||||
assert.ok(c1 >= 0 && c1 < c2 && c2 < c3, 'components keep the load-file order');
|
||||
assert.strictEqual(capMemHtml(null), '');
|
||||
});
|
||||
|
||||
test('capMemHtml shows the server platform requirement and int support', () => {
|
||||
const mem = memFixture();
|
||||
mem.requires_java_card = '2.2.2';
|
||||
mem.requires_framework = '1.3';
|
||||
mem.needs_int = true;
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(out.includes('Java Card \u2265 2.2.2 \u00b7 CAP format 2.1 \u00b7 int support'), out);
|
||||
// the server value wins over the local fallback table (fixture import 1.0)
|
||||
assert.ok(!out.includes('2.1.1/2.1.2'), out);
|
||||
});
|
||||
|
||||
test('capMemHtml names the ETSI release the UICC API imports come from', () => {
|
||||
const mem = memFixture();
|
||||
mem.requires_java_card = '2.2.2';
|
||||
mem.requires_etsi_release = 'REL-7';
|
||||
mem.requires_etsi_basis = 'uicc.toolkit 1.4';
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(out.includes('Java Card \u2265 2.2.2 \u00b7 ETSI REL-7 (uicc.toolkit 1.4) \u00b7 CAP format 2.1'), out);
|
||||
// a CAP without uicc.toolkit (or an unmapped version) shows no ETSI part
|
||||
const noEtsi = capMemHtml(memFixture());
|
||||
assert.ok(!noEtsi.includes('ETSI'), noEtsi);
|
||||
});
|
||||
|
||||
test('capMemHtml falls back to the raw framework version outside the corpus', () => {
|
||||
const mem = memFixture();
|
||||
mem.imports = [{ aid: 'A0000000620101', minor: 0, major: 7, refs: 6 }];
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(out.includes('javacard.framework \u2265 7.0 \u00b7 CAP format 2.1'), out);
|
||||
assert.ok(!out.includes('Java Card'), out);
|
||||
});
|
||||
|
||||
test('capMemHtml skips the import section on a response without imports', () => {
|
||||
const mem = memFixture();
|
||||
delete mem.imports;
|
||||
mem.code = { method_component: 2048 };
|
||||
delete mem.nvram.requirement;
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(!out.includes('>Requires'), out);
|
||||
assert.ok(!out.includes('Java Card'), out); // no framework import -> no SDK hint
|
||||
assert.ok(out.includes('CAP format 2.1'), out);
|
||||
assert.ok(out.includes('NVRAM requirement'), out);
|
||||
assert.ok(out.includes('>2.1 kB</td>'), out);
|
||||
});
|
||||
|
||||
test('capMemHtml shows a dash for an unresolved import name (the AID column carries it)', () => {
|
||||
const mem = memFixture();
|
||||
mem.imports = [{ aid: 'A1130001180001', minor: 0, major: 1, refs: 2 }];
|
||||
const out = capMemHtml(mem);
|
||||
const req = out.slice(out.indexOf('Requires (1)'), out.indexOf('Memory</div>'));
|
||||
assert.ok(req.includes('>\u2014</td>'), req); // no resolved name: a dash
|
||||
assert.ok(req.includes('>A1130001180001</td>'), req); // the AID column carries it
|
||||
assert.ok(req.includes('>\u2265 1.0</td>'), req);
|
||||
assert.ok(req.includes('>2</td>'), req); // refs
|
||||
assert.ok(!out.includes('Java Card'), out); // no framework import
|
||||
assert.ok(out.includes('CAP format 2.1'), out);
|
||||
});
|
||||
|
||||
test('capMemHtml falls back to the bytecode size on older server responses', () => {
|
||||
const mem = memFixture();
|
||||
delete mem.code.load_file;
|
||||
delete mem.nvram.requirement;
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(out.includes('NVRAM requirement'), out);
|
||||
assert.ok(out.includes('>2.1 kB</td>'), out);
|
||||
});
|
||||
|
||||
test('capMemHtml drops the components section without a component list', () => {
|
||||
const mem = memFixture();
|
||||
delete mem.components;
|
||||
const out = capMemHtml(mem);
|
||||
assert.ok(!out.includes('>Components'), out);
|
||||
assert.ok(out.includes('>Memory</div>'), out);
|
||||
});
|
||||
|
||||
test('capQuotaText renders decimal bytes with the hex coding', () => {
|
||||
const txt = capQuotaText({ c6: 27246, c7: 270, c8: 5143 });
|
||||
assert.strictEqual(txt, 'C6=27246 B (0x6A6E) \u00b7 C7=270 B (0x010E) \u00b7 C8=5143 B (0x1417)');
|
||||
assert.strictEqual(capQuotaText({}), 'C6=0 B (0x0000) \u00b7 C7=0 B (0x0000) \u00b7 C8=0 B (0x0000)');
|
||||
});
|
||||
|
||||
test('capGateOk gates the RAM install op and the scripts form', () => {
|
||||
resetState();
|
||||
globalThis.document = { getElementById: id => (id === 'ram-op' ? { value: 'install-cap' } : null) };
|
||||
assert.strictEqual(capGateOk('ram'), false);
|
||||
_capAnalysis.ram.status = 'ok';
|
||||
assert.strictEqual(capGateOk('ram'), true);
|
||||
_capAnalysis.scripts.status = 'error';
|
||||
assert.strictEqual(capGateOk('scripts'), false);
|
||||
// other RAM ops (explore/delete) are not gated
|
||||
globalThis.document = { getElementById: id => (id === 'ram-op' ? { value: 'explore' } : null) };
|
||||
assert.strictEqual(capGateOk('ram'), true);
|
||||
});
|
||||
|
||||
test('capAnalyzeFile stores the estimate and ignores stale responses', async () => {
|
||||
resetState();
|
||||
const pending = [];
|
||||
globalThis.ramReadFileHex = async f => f.name + 'hex';
|
||||
globalThis.pysimFetch = (url, body) => new Promise(resolve => pending.push(resolve));
|
||||
const f1 = { name: 'a.cap', size: 1, lastModified: 1 };
|
||||
const f2 = { name: 'b.cap', size: 2, lastModified: 2 };
|
||||
const p1 = capAnalyzeFile(f1, 'ram');
|
||||
const p2 = capAnalyzeFile(f2, 'ram');
|
||||
assert.strictEqual(_capAnalysis.ram.status, 'analyzing');
|
||||
// let both file reads settle so the fetches are in flight
|
||||
for (let i = 0; i < 10 && pending.length < 2; i++) await Promise.resolve();
|
||||
// resolve the first (stale) response after the second selection
|
||||
pending[0]({ ok: true, memory: { code: { method_component: 111 } } });
|
||||
pending[1]({ ok: true, memory: { code: { method_component: 222 } } });
|
||||
await Promise.all([p1, p2]);
|
||||
assert.strictEqual(_capAnalysis.ram.status, 'ok');
|
||||
assert.strictEqual(_capAnalysis.ram.memory.code.method_component, 222);
|
||||
assert.strictEqual(_capAnalysis.ram.key, capFileKey(f2));
|
||||
globalThis.pysimFetch = undefined;
|
||||
});
|
||||
|
||||
test('capAnalyzeFile records server and network failures', async () => {
|
||||
resetState();
|
||||
globalThis.ramReadFileHex = async () => '00';
|
||||
globalThis.pysimFetch = async () => ({ ok: false, error: 'cap parse failed: File is not a zip file' });
|
||||
await capAnalyzeFile({ name: 'bad.cap', size: 1, lastModified: 1 }, 'scripts');
|
||||
assert.strictEqual(_capAnalysis.scripts.status, 'error');
|
||||
assert.ok(_capAnalysis.scripts.error.includes('not a zip file'), _capAnalysis.scripts.error);
|
||||
globalThis.pysimFetch = async () => { throw new Error('boom'); };
|
||||
await capAnalyzeFile({ name: 'net.cap', size: 1, lastModified: 1 }, 'scripts');
|
||||
assert.strictEqual(_capAnalysis.scripts.status, 'error');
|
||||
assert.strictEqual(_capAnalysis.scripts.error, 'boom');
|
||||
});
|
||||
|
||||
test('the CAP inputs and their action buttons are wired', () => {
|
||||
assert.match(html, /id="ram-cap-file"[^>]*onchange="ramCapFileChanged\(\)"/);
|
||||
assert.match(html, /id="scripts-cap-file"[^>]*onchange="scriptsCapFileChanged\(\)"/);
|
||||
assert.match(html, /data-cap-gate="ram"/);
|
||||
assert.match(html, /data-cap-gate="scripts"/);
|
||||
assert.match(html, /id="ram-cap-info"/);
|
||||
assert.match(html, /id="scripts-cap-info"/);
|
||||
assert.match(html, /\/api\/cap-info/);
|
||||
// the report lives in a bordered fieldset with the title in the border
|
||||
// (the simulator/eSIM pattern); the JS fills the body div
|
||||
assert.match(html, /<fieldset id="ram-cap-info"[^>]*>\s*<legend[^>]*data-l10n="CAP details">/);
|
||||
assert.match(html, /<fieldset id="scripts-cap-info"[^>]*>\s*<legend[^>]*data-l10n="CAP details">/);
|
||||
assert.match(html, /id="ram-cap-body"/);
|
||||
assert.match(html, /id="scripts-cap-body"/);
|
||||
});
|
||||
|
||||
test('capRenderAnalysis toggles the border box and fills its body', () => {
|
||||
eval(extractFunc(html, 'capRenderAnalysis'));
|
||||
const box = { hidden: true, classList: {
|
||||
add() { box.hidden = true; },
|
||||
remove() { box.hidden = false; },
|
||||
} };
|
||||
const body = { innerHTML: '' };
|
||||
globalThis.document = { getElementById: id => ({
|
||||
'ram-cap-info': box, 'ram-cap-body': body,
|
||||
}[id] || null) };
|
||||
// idle: hidden and empty
|
||||
_capAnalysis.ram = { key: null, status: 'idle', memory: null, error: null, token: 0 };
|
||||
capRenderAnalysis('ram');
|
||||
assert.ok(box.hidden && body.innerHTML === '');
|
||||
// a finished analysis: shown, body carries the report
|
||||
_capAnalysis.ram = { key: null, status: 'ok', memory: memFixture(), error: null, token: 0 };
|
||||
capRenderAnalysis('ram');
|
||||
assert.ok(!box.hidden, 'the box is shown');
|
||||
assert.ok(body.innerHTML.includes('>Requires (1)</div>'), body.innerHTML);
|
||||
// a failure: shown with the error and a retry
|
||||
_capAnalysis.ram = { key: null, status: 'error', memory: null, error: 'bad zip', token: 0 };
|
||||
capRenderAnalysis('ram');
|
||||
assert.ok(!box.hidden && body.innerHTML.includes('bad zip') && body.innerHTML.includes('Retry'), body.innerHTML);
|
||||
// analyzing: shown with the progress line
|
||||
_capAnalysis.ram = { key: null, status: 'analyzing', memory: null, error: null, token: 0 };
|
||||
capRenderAnalysis('ram');
|
||||
assert.ok(body.innerHTML.includes('Analyzing CAP file...'), body.innerHTML);
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
||||
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
|
||||
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n'
|
||||
+ 'var _pysimAdmKey = null;\n'
|
||||
+ 'var _pysimAdmCanVerify = false;\nvar _pysimAdmAttemptsLeft = null;\n'
|
||||
+ 'var _pysimAdmBlocked = false;\nvar _pysimAdmVerifying = false;\n'
|
||||
+ 'var _pysimAdmStateKey = null;\nvar _pysimLastStatus = null;\n'
|
||||
+ 'var _pysimHeaderIccid = undefined;\nvar _pysimHeaderScp80 = undefined;\nvar _pysimHeaderScp81 = undefined;\n'
|
||||
+ 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n';
|
||||
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
||||
// the status poll also feeds the RAM operation modal (DOM-bound, covered in
|
||||
// ram.test.js) - stub it for these state tests
|
||||
code += 'var ramOpProgress = function() {};\n';
|
||||
code += extractFunc(html, 'pysimRefresh', true) + '\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
||||
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
|
||||
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateAdmIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimAdmResetAttempts') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
|
||||
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
|
||||
code += extractFunc(html, 'cardsAdmPresent') + '\n';
|
||||
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
|
||||
code += extractFunc(html, 'cardsTarList') + '\n';
|
||||
code += extractFunc(html, 'spKeysetList') + '\n';
|
||||
code += extractFunc(html, 'spKeysetKvnOf') + '\n';
|
||||
code += extractFunc(html, 'cardsScp80Complete') + '\n';
|
||||
code += extractFunc(html, 'cardsScp81Complete') + '\n';
|
||||
code += extractFunc(html, 'pysimSetServerAvailable') + '\n';
|
||||
code += '\nglobalThis.esc = s => s;\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
code += 'globalThis.cardsRender = () => {};\n'; // equipped-preset highlight
|
||||
eval(code);
|
||||
|
||||
function fakeIndicator() {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
classes, textContent: '', title: null,
|
||||
classList: {
|
||||
add: (...c) => c.forEach(x => classes.add(x)),
|
||||
remove: (...c) => c.forEach(x => classes.delete(x)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute: (k, v) => { if (k === 'title') el.title = v; },
|
||||
removeAttribute: (k) => { if (k === 'title') el.title = null; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const el = { textContent: 'status line', innerHTML: '' };
|
||||
const adm = fakeIndicator();
|
||||
const iccidEl = fakeIndicator();
|
||||
const scp80El = fakeIndicator();
|
||||
const scp81El = fakeIndicator();
|
||||
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0, autoIccid: [] };
|
||||
_pysimCardStateKey = null;
|
||||
_pysimCardSession = null;
|
||||
_pysimProactiveSeq = null;
|
||||
_pysimAdmVerified = null;
|
||||
_pysimAdmKey = null;
|
||||
_pysimAdmCanVerify = false;
|
||||
_pysimAdmAttemptsLeft = null;
|
||||
_pysimAdmBlocked = false;
|
||||
_pysimAdmVerifying = false;
|
||||
_pysimAdmStateKey = null;
|
||||
_pysimLastStatus = null;
|
||||
_pysimHeaderIccid = undefined;
|
||||
_pysimHeaderScp80 = undefined;
|
||||
_pysimHeaderScp81 = undefined;
|
||||
_pysimServerAvailable = null;
|
||||
_cardsAutoIccid = null;
|
||||
_pysimCardIccid = null;
|
||||
globalThis.document = {
|
||||
getElementById: id => id === 'state-indicator-adm' ? adm
|
||||
: (id === 'state-indicator-iccid' ? iccidEl
|
||||
: (id === 'state-indicator-scp80' ? scp80El
|
||||
: (id === 'state-indicator-scp81' ? scp81El : el))),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
globalThis.cards = [];
|
||||
globalThis.cardsFindByIccid = () => -1;
|
||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
globalThis.netStateRender = () => {};
|
||||
globalThis.isViewVisible = () => true;
|
||||
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
|
||||
globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; };
|
||||
// the SCP80/RAM preset warning banner is DOM-bound (covered in
|
||||
// preset_warning.test.js) - stub it for these state tests
|
||||
globalThis.spPresetWarningRender = () => {};
|
||||
return { el, adm, iccidEl, scp80El, scp81El, calls };
|
||||
}
|
||||
|
||||
function status(extra) {
|
||||
return Object.assign({ connected: false, card_present: false, equipping: false, auto_equip: false, card_session: 1 }, extra);
|
||||
}
|
||||
|
||||
test('disconnect without card shows the no-card message', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({}));
|
||||
assert.deepStrictEqual(calls.connected, [false]);
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with card present shows the Equip hint when auto-equip is off', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true }));
|
||||
assert.ok(el.innerHTML.includes('Card inserted — press Equip'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('disconnect with auto-equip shows the initializing message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ card_present: true, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('initializing states show the animated indicator', () => {
|
||||
for (const extra of [{ card_present: true, equipping: true },
|
||||
{ card_present: true, auto_equip: true }]) {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status(extra));
|
||||
assert.deepStrictEqual(calls.connected, ['spin'], JSON.stringify(extra));
|
||||
}
|
||||
});
|
||||
|
||||
test('a cardless session without auto-equip shows the static no-card icon', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({}));
|
||||
assert.deepStrictEqual(calls.connected, [false]);
|
||||
});
|
||||
|
||||
test('Check status keeps the icon in step with the card state', async () => {
|
||||
for (const entry of [
|
||||
[{ connected: true }, true],
|
||||
[{ connected: false, card_present: true, equipping: true }, 'spin'],
|
||||
[{ connected: false, card_present: true, auto_equip: true }, 'spin'],
|
||||
[{ connected: false }, false],
|
||||
]) {
|
||||
const { calls } = setup();
|
||||
globalThis.pysimFetch = async () => entry[0];
|
||||
await pysimRefresh();
|
||||
assert.deepStrictEqual(calls.connected, [entry[1]], JSON.stringify(entry[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('unchanged state key does not touch the UI again', () => {
|
||||
const { el, calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
el.innerHTML = 'unchanged';
|
||||
calls.connected.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 7 }));
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
assert.strictEqual(el.innerHTML, 'unchanged');
|
||||
});
|
||||
|
||||
test('connected restores the UI and reloads card data', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
|
||||
assert.deepStrictEqual(calls.connected, [true]);
|
||||
assert.deepStrictEqual(calls.resets, [true]);
|
||||
assert.deepStrictEqual(calls.autoIccid, ['8970119000004600098']);
|
||||
});
|
||||
|
||||
test('a disconnect clears the ICCID auto-selection guard', () => {
|
||||
setup();
|
||||
_cardsAutoIccid = '8970119000004600098';
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 5 }));
|
||||
assert.strictEqual(_cardsAutoIccid, null);
|
||||
});
|
||||
|
||||
test('the last status ICCID is kept for the From card button', () => {
|
||||
setup();
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
|
||||
assert.strictEqual(_pysimCardIccid, '8970119000004600098');
|
||||
// equipped but unreadable -> null (button disabled)
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null }));
|
||||
assert.strictEqual(_pysimCardIccid, null);
|
||||
// disconnect clears it as well
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 6 }));
|
||||
assert.strictEqual(_pysimCardIccid, null);
|
||||
});
|
||||
|
||||
test('card session change triggers a data reset', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 3 }));
|
||||
calls.resets.length = 0;
|
||||
pysimCardStateUpdate(status({ card_session: 4 }));
|
||||
assert.deepStrictEqual(calls.resets, [false]);
|
||||
});
|
||||
|
||||
test('first observation does not trigger a reset on its own', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ card_session: 9 }));
|
||||
assert.deepStrictEqual(calls.resets, []);
|
||||
});
|
||||
|
||||
test('payload without connected flag is ignored', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate({ reader: 'x' });
|
||||
pysimCardStateUpdate(null);
|
||||
assert.deepStrictEqual(calls.connected, []);
|
||||
});
|
||||
|
||||
test('availability state and control gating follow server/card state', () => {
|
||||
_pysimServerAvailable = null;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'server-down');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'server-down'), true);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'server-down'), true);
|
||||
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = false;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'no-card');
|
||||
assert.strictEqual(pysimControlDisabled('server', 'no-card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('card', 'no-card'), true);
|
||||
|
||||
_pysimCardEquipped = true;
|
||||
assert.strictEqual(pysimAvailabilityState(), 'card');
|
||||
assert.strictEqual(pysimControlDisabled('card', 'card'), false);
|
||||
assert.strictEqual(pysimControlDisabled('server', 'card'), false);
|
||||
});
|
||||
|
||||
test('no card with auto-equip enabled still shows the no-card message', () => {
|
||||
const { el } = setup();
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, auto_equip: true }));
|
||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
|
||||
});
|
||||
|
||||
test('proactive log refreshes when the status sequence changes', () => {
|
||||
const { calls } = setup();
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||
assert.strictEqual(calls.proactive, 1);
|
||||
pysimCardStateUpdate(status({ proactive_seq: 8 }));
|
||||
assert.strictEqual(calls.proactive, 2);
|
||||
});
|
||||
|
||||
test('proactive log is not refreshed while the phone view is hidden', () => {
|
||||
const { calls } = setup();
|
||||
globalThis.isViewVisible = () => false;
|
||||
pysimCardStateUpdate(status({ proactive_seq: 3 }));
|
||||
assert.strictEqual(calls.proactive, 0);
|
||||
});
|
||||
|
||||
test('pysimProactiveSeqChanged tracks the last sequence', () => {
|
||||
_pysimProactiveSeq = null;
|
||||
assert.ok(pysimProactiveSeqChanged(4));
|
||||
assert.ok(!pysimProactiveSeqChanged(4));
|
||||
assert.ok(pysimProactiveSeqChanged(5));
|
||||
assert.ok(!pysimProactiveSeqChanged(undefined));
|
||||
assert.ok(!pysimProactiveSeqChanged(null));
|
||||
});
|
||||
|
||||
test('pysimStkStatusChanged detects menu state transitions', () => {
|
||||
_pysimStkSig = null;
|
||||
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
|
||||
assert.ok(!pysimStkStatusChanged(null));
|
||||
});
|
||||
|
||||
test('the header ADM badge shows verified / not verified / hidden', () => {
|
||||
const { adm, calls } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
|
||||
assert.ok(!adm.classes.has('hidden'));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✓');
|
||||
assert.ok(adm.classes.has('text-emerald-600'));
|
||||
assert.ok(adm.classes.has('dark:text-emerald-400'));
|
||||
assert.strictEqual(adm.title, 'Verified — no ADM key in the card preset');
|
||||
// an unchanged state must not rewrite the badge
|
||||
adm.textContent = '';
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true }));
|
||||
assert.strictEqual(adm.textContent, '', 'unchanged ADM state rewrote the badge');
|
||||
// verification lost (e.g. card reset)
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: false }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✗');
|
||||
assert.ok(adm.classes.has('text-red-500'));
|
||||
assert.ok(!adm.classes.has('text-emerald-600'));
|
||||
assert.strictEqual(adm.title, 'No ADM key in the card preset — not verified');
|
||||
// no card session hides it
|
||||
pysimCardStateUpdate(status({ connected: false }));
|
||||
assert.ok(adm.classes.has('hidden'));
|
||||
assert.strictEqual(adm.title, null);
|
||||
// the ADM update must not disturb the connect/reset flow
|
||||
assert.deepStrictEqual(calls.connected, [true, false]);
|
||||
});
|
||||
|
||||
test('the ADM badge marks an ADM key in the matching preset', () => {
|
||||
const { adm } = setup();
|
||||
const st = extra => status(Object.assign({ connected: true, iccid: '89701450001700031958' }, extra));
|
||||
globalThis.cards = [{ name: 'C', adm: '0011' }];
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
pysimCardStateUpdate(st({ adm_verified: true }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✓ ⚿');
|
||||
assert.strictEqual(adm.title, 'ADM key in the card preset — click to verify');
|
||||
assert.ok(adm.classes.has('cursor-pointer'), 'the badge is clickable with a preset key');
|
||||
// key present, verification lost (e.g. card reset)
|
||||
pysimCardStateUpdate(st({ adm_verified: false }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✗ ⚿');
|
||||
assert.strictEqual(adm.title, 'ADM key in the card preset — click to verify');
|
||||
// verified manually, preset has no ADM -> no key glyph
|
||||
globalThis.cards = [];
|
||||
pysimCardStateUpdate(st({ adm_verified: true }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✓');
|
||||
assert.strictEqual(adm.title, 'Verified — no ADM key in the card preset');
|
||||
// whitespace-only preset ADM counts as absent
|
||||
globalThis.cards = [{ name: 'C', adm: ' ' }];
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
pysimCardStateUpdate(st({ adm_verified: true }));
|
||||
assert.strictEqual(adm.textContent, 'ADM ✓');
|
||||
});
|
||||
|
||||
test('the header SCP80/SCP81 markers follow the matching preset', () => {
|
||||
const { scp80El, scp81El } = setup();
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
pysimCardStateUpdate(status({ connected: true, card_session: 2, iccid: '89701450001700031958' }));
|
||||
assert.strictEqual(scp80El.textContent, 'SCP80');
|
||||
assert.strictEqual(scp81El.textContent, 'SCP81');
|
||||
assert.ok(!scp80El.classes.has('hidden'));
|
||||
assert.ok(!scp81El.classes.has('hidden'));
|
||||
// disconnect hides them again
|
||||
pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 3 }));
|
||||
assert.ok(scp80El.classes.has('hidden'));
|
||||
assert.ok(scp81El.classes.has('hidden'));
|
||||
assert.strictEqual(scp80El.textContent, '');
|
||||
});
|
||||
|
||||
test('losing the server hides the ADM badge and the preset markers', () => {
|
||||
const { adm, scp80El, scp81El } = setup();
|
||||
globalThis.cards = [{ name: 'C', adm: '0011', kic: '15', kid: '15',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
pysimCardStateUpdate(status({ connected: true, adm_verified: true, iccid: '89701450001700031958' }));
|
||||
assert.ok(!adm.classes.has('hidden'));
|
||||
assert.ok(!scp80El.classes.has('hidden'));
|
||||
assert.ok(!scp81El.classes.has('hidden'));
|
||||
pysimSetServerAvailable(false);
|
||||
assert.ok(adm.classes.has('hidden'));
|
||||
assert.ok(scp80El.classes.has('hidden'));
|
||||
assert.ok(scp81El.classes.has('hidden'));
|
||||
});
|
||||
|
||||
test('the header indicator prints the equipped card ICCID', () => {
|
||||
const { iccidEl } = setup();
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '89701450001700031958' }));
|
||||
assert.strictEqual(iccidEl.textContent, '89701450001700031958');
|
||||
assert.ok(!iccidEl.classes.has('hidden'));
|
||||
// equipped but unreadable -> hidden again
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null }));
|
||||
assert.strictEqual(iccidEl.textContent, '');
|
||||
assert.ok(iccidEl.classes.has('hidden'));
|
||||
// losing the server hides it too
|
||||
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '89701450001700031958' }));
|
||||
pysimSetServerAvailable(false);
|
||||
assert.ok(iccidEl.classes.has('hidden'));
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
|
||||
'cardsTarEntry', 'cardsTarMsl', 'cardsApplyFields', 'cardsApply',
|
||||
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr', 'spPresetIdx',
|
||||
'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective', 'spCntrAhead',
|
||||
'spCntrAdjust',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
|
||||
'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
|
||||
'ramKeysetGuard',
|
||||
'esc',
|
||||
]) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||||
+ extractConst(html, 'CARDS_MSL_DEFAULT') + code;
|
||||
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
|
||||
+ 'globalThis.spMslWarningRender = function() {};\n'
|
||||
+ 'globalThis.pysimApplyAvailability = function() {};\n' + code;
|
||||
code = 'var _genSpBuildStub = function() {};\n' + code;
|
||||
eval(code);
|
||||
globalThis._genSpBuild = () => {};
|
||||
globalThis.spShowSizeInfo = () => {};
|
||||
globalThis.spKeysetSync = globalThis.spKeysetSync || (() => {});
|
||||
globalThis.t = s => s;
|
||||
|
||||
// The SP form is a working copy: the preset is the source of truth, and
|
||||
// every SCP80/RAM operation re-reads it before starting (v3.6.3). The plain
|
||||
// SCP80 send keeps a hand-edited packet TAR (v3.6.21).
|
||||
function fakeEnv(selValue, selId, presetCntr) {
|
||||
const els = {};
|
||||
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-idx', 'sp-kic-alg',
|
||||
'sp-kid-idx', 'sp-kid-alg', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) {
|
||||
els[id] = { value: '' };
|
||||
}
|
||||
// the TAR is a select (v3.26.0): the stub models the surface the renderer
|
||||
// touches - setting innerHTML parses the option values and selectedIndex
|
||||
// moves the value, like a real <select>
|
||||
els['sp-tar'] = {
|
||||
value: '', disabled: false, options: [], _sel: 0, _html: '',
|
||||
set innerHTML(v) {
|
||||
this._html = v;
|
||||
this.options = Array.from(v.matchAll(/value="([^"]*)"/g), m => ({ value: m[1] }));
|
||||
},
|
||||
get innerHTML() { return this._html; },
|
||||
set selectedIndex(i) { this._sel = i; if (this.options[i]) this.value = this.options[i].value; },
|
||||
get selectedIndex() { return this._sel; },
|
||||
};
|
||||
// the combined key bytes the server reads (updateSpKic maintains them in
|
||||
// the real form): the fake preset's keyset resolves to kvn 1
|
||||
els['sp-kic-hex'] = { value: '15' };
|
||||
els['sp-kid-hex'] = { value: '15' };
|
||||
els[selId || 'sp-card-sel'] = { value: selValue };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'C', cntr: presetCntr,
|
||||
kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB',
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
|
||||
globalThis._spTarKey = 'uiccRfm';
|
||||
globalThis.updateSpKic = () => {};
|
||||
globalThis.updateSpKid = () => {};
|
||||
globalThis.spInvalidate = () => {};
|
||||
globalThis.updateSp = () => {};
|
||||
let gen = 0;
|
||||
globalThis.genSp = () => { gen++; };
|
||||
// cardsApply refreshes the preview directly (the guarded genSp() is the
|
||||
// Generate button's path), so the counter stub is _genSpBuild
|
||||
globalThis._genSpBuild = () => { gen++; };
|
||||
return { els, gen: () => gen };
|
||||
}
|
||||
|
||||
test('spRefreshFromPreset keeps an explicit counter override, empty = auto', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '00000000AA');
|
||||
env.els['sp-cntr'].value = '0000000001'; // explicit override
|
||||
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '0000000001',
|
||||
'a typed counter survives the pre-send refresh');
|
||||
assert.ok(env.gen() > 0, 'the packet must be regenerated from the preset');
|
||||
// an empty field means "auto": the server seeds the counter, and the
|
||||
// preset's next value is shown as the placeholder (v3.20.0)
|
||||
env.els['sp-cntr'].value = '';
|
||||
spRefreshFromPreset('sp-card-sel');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '');
|
||||
assert.match(env.els['sp-cntr'].placeholder, /00000000AA/);
|
||||
// no preset selected: the manual form is left alone
|
||||
globalThis.cards = [];
|
||||
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '');
|
||||
});
|
||||
|
||||
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
|
||||
const env = fakeEnv('0', 'ram-card-sel', '0000000010');
|
||||
assert.strictEqual(spRefreshFromPreset('ram-card-sel'), '0000000010');
|
||||
assert.strictEqual(_spTarKey, 'isd');
|
||||
assert.strictEqual(env.els['sp-tar'].value, '000000');
|
||||
});
|
||||
|
||||
test('the pre-send refresh keeps the selected packet TAR', () => {
|
||||
// v3.26.0: the TAR is a preset selector - the pre-send refresh must keep
|
||||
// the operator's selection (a TAR outside the preset is not offered)
|
||||
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
|
||||
env.els['sp-tar'].value = 'B00000'; // the UICC RFM entry of the preset
|
||||
assert.strictEqual(spRefreshFromPreset('sp-card-sel', true), '0000000020');
|
||||
assert.strictEqual(env.els['sp-tar'].value, 'B00000', 'the selected TAR must survive the send');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '', 'the counter field stays an override');
|
||||
assert.match(env.els['sp-cntr'].placeholder, /0000000020/);
|
||||
// an explicit preset apply (or a pack) selects the operation's TAR
|
||||
cardsApplyFields(0);
|
||||
assert.strictEqual(env.els['sp-tar'].value, 'B00000');
|
||||
});
|
||||
|
||||
test('cardsApplyFields leaves the counter to the server (auto) and shows it as a placeholder', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '0000000007');
|
||||
env.els['sp-cntr'].value = 'nonsense'; // an old override
|
||||
assert.strictEqual(cardsApplyFields(0), true);
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '',
|
||||
'applying a preset clears the override (empty = the server seeds it)');
|
||||
assert.match(env.els['sp-cntr'].placeholder, /0000000007/);
|
||||
assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet');
|
||||
cardsApply(0);
|
||||
assert.strictEqual(env.gen(), 1);
|
||||
assert.strictEqual(cardsApplyFields(3), false);
|
||||
});
|
||||
|
||||
test('the counter +/- buttons start from the preset value and create an override', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
|
||||
spCntrAdjust(1);
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '0000000021');
|
||||
spCntrAdjust(-1);
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '0000000020');
|
||||
});
|
||||
|
||||
test('spCntrEffective mirrors the server seed: matched keyset, else 0', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '0000000020');
|
||||
assert.strictEqual(spCntrEffective(), '0000000020');
|
||||
env.els['sp-cntr'].value = '0000000042'; // a typed override wins
|
||||
assert.strictEqual(spCntrEffective(), '0000000042');
|
||||
env.els['sp-cntr'].value = '';
|
||||
env.els['sp-kic-hex'].value = '00'; // keyless: no keyset
|
||||
env.els['sp-kid-hex'].value = '00';
|
||||
assert.strictEqual(spCntrEffective(), '0000000000',
|
||||
'the server seeds nothing for kvn 0 - the packet carries 0');
|
||||
env.els['sp-kic-hex'].value = '15';
|
||||
env.els['sp-kid-hex'].value = '15';
|
||||
globalThis.cards = []; // no preset: no seed
|
||||
assert.strictEqual(spCntrEffective(), '0000000000');
|
||||
});
|
||||
|
||||
test('spPorAccepted treats a missing PoR and por_ok as accepted', () => {
|
||||
assert.strictEqual(spPorAccepted(undefined), true);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true);
|
||||
// 0x0B: the real response follows as an SMS-SUBMIT - the packet was consumed
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'actual_response_sms_submit' }), true);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'cntr_low' }), false);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'rc_cc_ds_failed' }), false);
|
||||
});
|
||||
@@ -0,0 +1,718 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
let code = '';
|
||||
const ASYNC_FNS = ['cardsAdd', 'cardsImport', 'cardsRemove'];
|
||||
for (const fn of ['cardsFormValues', 'cardsClearForm', 'cardsEdit',
|
||||
'cardsAdd', 'cardsImport', 'cardsRemove', 'ramCardIdxAfterRemove',
|
||||
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrEffective',
|
||||
'spCntrAhead', 'spCntrLocalSync', 'ramSaveCntr',
|
||||
'cardsApplyFields', 'cardsApply', 'ramApplyCard',
|
||||
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
|
||||
'spTarKeyForPack', 'spPresetTar', 'packToSp',
|
||||
'esc', 'escHtml',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
|
||||
'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard',
|
||||
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
|
||||
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'spMslWarningText',
|
||||
'cardsTarRowHtml', 'cardsTarRowsRender', 'cardsTarAdd', 'cardsTarRemove',
|
||||
'cardsFreeTarsFromForm', 'cardsTarsFromTable', 'cardsTarsFromForm', 'cardsTarsReset',
|
||||
'cardsShowList', 'cardsShowEditor', 'cardsSetFormMode', 'cardsNew',
|
||||
'cardsCancelEdit']) {
|
||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||
}
|
||||
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||||
+ extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC')
|
||||
+ 'var _cardsKeysetCount = 0;\nvar _cardsTarCount = 0;\n'
|
||||
+ 'globalThis.spPresetWarningRender = function() {};\n'
|
||||
+ 'globalThis.spMslWarningRender = function() {};\n'
|
||||
+ 'globalThis.pysimApplyAvailability = function() {};\n'
|
||||
+ 'globalThis.navRecord = function() {};\n'
|
||||
+ 'globalThis.navReplace = function() {};\n' + code;
|
||||
eval(code);
|
||||
globalThis.t = s => s;
|
||||
// The dialog sweep replaced the native alert/confirm with the helpers: route
|
||||
// them to the native stubs each test installs on globalThis.
|
||||
globalThis.pysimAlert = m => globalThis.alert(m);
|
||||
globalThis.pysimConfirm = async m => globalThis.confirm(m);
|
||||
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
|
||||
|
||||
const CARD_IDS = ['cards-name','cards-iccid','cards-release','cards-pin1','cards-puk1','cards-pin2','cards-puk2','cards-adm',
|
||||
'cards-tars','cards-list-view','cards-editor-view','cards-editor-title','cards-new-btn',
|
||||
'cards-psk-id','cards-psk-key','cards-keysets',
|
||||
'cards-add-btn','cards-cancel-btn',
|
||||
// the keyset editor rows (two rows is enough for the tests)
|
||||
'cards-ks-0-kvn','cards-ks-0-kic','cards-ks-0-kid','cards-ks-0-kic-key',
|
||||
'cards-ks-0-kid-key','cards-ks-0-cntr',
|
||||
'cards-ks-1-kvn','cards-ks-1-kic','cards-ks-1-kid','cards-ks-1-kic-key',
|
||||
'cards-ks-1-kid-key','cards-ks-1-cntr',
|
||||
// the TAR table rows: 0-2 are the fixed role rows, 3+ the free rows
|
||||
'cards-tr-0-tar','cards-tr-0-msl','cards-tr-1-tar','cards-tr-1-msl',
|
||||
'cards-tr-2-tar','cards-tr-2-msl',
|
||||
'cards-tr-3-tar','cards-tr-3-msl','cards-tr-3-desc','cards-tr-3-keyset',
|
||||
'cards-tr-4-tar','cards-tr-4-msl','cards-tr-4-desc','cards-tr-4-keyset'];
|
||||
const SP_IDS = ['sp-card-sel','sp-tar','sp-spi1','sp-spi2','sp-spi2-sm','sp-spi2-hex',
|
||||
'sp-kic-idx','sp-kic-alg','sp-kic-hex','sp-kid-idx','sp-kid-alg','sp-kid-hex',
|
||||
'sp-cntr','sp-kic-key','sp-kid-key','sp-apdu','sp-result','sp-spi1-hex'];
|
||||
const CHAIN_IDS = ['chain-sim-preview','chain-usim-preview','chain-ram-preview',
|
||||
'ber-result','hota-preview'];
|
||||
|
||||
function ksRow(els, i, ks) {
|
||||
for (const field of ['kic', 'kid', 'kic-key', 'kid-key', 'cntr']) {
|
||||
const id = 'cards-ks-' + i + '-' + field;
|
||||
if (!els[id]) els[id] = fakeEl();
|
||||
els[id].value = ks[field === 'kic-key' ? 'kicKey' : (field === 'kid-key' ? 'kidKey' : field)] || '';
|
||||
}
|
||||
}
|
||||
|
||||
function tarRow(els, i, e) {
|
||||
for (const field of ['tar', 'msl', 'desc', 'keyset']) {
|
||||
const id = 'cards-tr-' + i + '-' + field;
|
||||
if (!els[id]) els[id] = fakeEl();
|
||||
els[id].value = (e[field] === undefined || e[field] === null) ? '' : String(e[field]);
|
||||
}
|
||||
}
|
||||
|
||||
function presetFixture(extra) {
|
||||
return Object.assign({
|
||||
name: 'C',
|
||||
keysets: [{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }],
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }],
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
// A <select>-like fake (the TAR selector): setting innerHTML parses the option
|
||||
// values (and the selected one) and selectedIndex moves the value, like a real
|
||||
// element. The accessors need defineProperties - Object.assign would flatten
|
||||
// them into data properties.
|
||||
function selectEl() {
|
||||
const el = fakeEl();
|
||||
el.options = [];
|
||||
el._sel = 0;
|
||||
el._html = '';
|
||||
el.disabled = false;
|
||||
Object.defineProperties(el, {
|
||||
innerHTML: {
|
||||
get() { return this._html; },
|
||||
set(v) {
|
||||
this._html = v;
|
||||
this.options = Array.from(v.matchAll(/value="([^"]*)"/g), m => ({ value: m[1] }));
|
||||
const sel = v.match(/<option value="([^"]*)" selected/);
|
||||
if (sel) this.value = sel[1];
|
||||
},
|
||||
},
|
||||
selectedIndex: {
|
||||
get() { return this._sel; },
|
||||
set(i) { this._sel = i; if (this.options[i]) this.value = this.options[i].value; },
|
||||
},
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
focus() {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
toggle: (c, on) => {
|
||||
const want = on === undefined ? !classes.has(c) : !!on;
|
||||
if (want) classes.add(c); else classes.delete(c);
|
||||
return want;
|
||||
},
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute() {}, removeAttribute() {},
|
||||
};
|
||||
}
|
||||
|
||||
let apiCalls = [];
|
||||
|
||||
function setup() {
|
||||
const els = {};
|
||||
for (const id of CARD_IDS.concat(SP_IDS, CHAIN_IDS)) els[id] = fakeEl();
|
||||
els['sp-tar'] = selectEl(); // the TAR selector (v3.26.0)
|
||||
globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
|
||||
globalThis.cards = [];
|
||||
globalThis._cardsEditIdx = null;
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
_cardsTarCount = 0;
|
||||
globalThis._spTarKey = 'isd';
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => s;
|
||||
globalThis.escHtml = s => s;
|
||||
globalThis.alert = () => {};
|
||||
globalThis.cardsSave = () => {};
|
||||
globalThis.cardsRender = () => {};
|
||||
globalThis.cardsRebuildSelect = () => {};
|
||||
globalThis.scp81PushPskMap = () => {};
|
||||
globalThis.cardsFindDuplicateIccid = () => -1;
|
||||
globalThis.ioStatus = () => {};
|
||||
globalThis.switchTab = () => {};
|
||||
globalThis.scp80SwitchSubtab = () => {};
|
||||
globalThis.updateSpKic = () => {};
|
||||
globalThis.updateSpKid = () => {};
|
||||
globalThis.spInvalidate = () => {};
|
||||
globalThis.updateSp = () => {};
|
||||
globalThis.genSp = () => {};
|
||||
globalThis._genSpBuild = () => {};
|
||||
globalThis.spShowSizeInfo = () => {};
|
||||
globalThis.ramRender = () => {};
|
||||
globalThis.switchTab = () => {};
|
||||
// the server store is the source of truth (v3.8.0): the Cards tab talks to
|
||||
// /api/presets* and reloads through cardsFetch()
|
||||
apiCalls = [];
|
||||
globalThis.pysimFetchOk = async (path, body) => {
|
||||
apiCalls.push({ path: path, body: body });
|
||||
return { ok: true, added: 1, skipped: 0 };
|
||||
};
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
apiCalls.push({ path: path, body: body });
|
||||
return { ok: true };
|
||||
};
|
||||
globalThis.cardsFetch = async () => true;
|
||||
return els;
|
||||
}
|
||||
|
||||
test('the preset TAR table helpers read the role entries and the MSLs', () => {
|
||||
const p = presetFixture({ tars: [
|
||||
{ role: 'isd', tar: 'AF4D01', msl: '1A' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '10' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'applet' }] });
|
||||
assert.strictEqual(cardsRoleTar(p, 'isd'), 'AF4D01');
|
||||
assert.strictEqual(cardsTarMsl(p, 'af4d02'), '0A');
|
||||
assert.strictEqual(cardsTarMsl(p, '123456'), '');
|
||||
assert.strictEqual(cardsTarEntry(p, 'AF4D02').desc, 'applet');
|
||||
// a missing role entry falls back to the spec default
|
||||
assert.strictEqual(cardsRoleTar({}, 'uiccRfm'), 'B00000');
|
||||
assert.strictEqual(cardsRoleTar(null, 'usimRfm'), 'B00001');
|
||||
assert.strictEqual(cardsRoleDefault('nope'), '');
|
||||
assert.strictEqual(cardsTarText(p),
|
||||
'ISD AF4D01\u00b71A UICC B00000\u00b710 ADF B00001\u00b716 AF4D02\u00b70A (applet)');
|
||||
assert.strictEqual(cardsTarText({}), '\u2014');
|
||||
});
|
||||
|
||||
test('spMslWarningText compares the SPI1 with the TAR MSL numerically', () => {
|
||||
const p = presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '10' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] });
|
||||
assert.strictEqual(spMslWarningText(p, '000000', '10'), '');
|
||||
assert.strictEqual(spMslWarningText(p, '000000', '1A'), '');
|
||||
assert.match(spMslWarningText(p, '000000', '0A'), /MSL/);
|
||||
// numeric, not lexicographic: '9' < '10'
|
||||
assert.match(spMslWarningText(p, '000000', '9'), /MSL/);
|
||||
// a TAR the preset does not carry (or a missing SPI1) is not a warning
|
||||
assert.strictEqual(spMslWarningText(p, 'AF4D01', '00'), '');
|
||||
assert.strictEqual(spMslWarningText(p, '000000', ''), '');
|
||||
});
|
||||
|
||||
test('preset completeness predicates used by the header markers', () => {
|
||||
const full = presetFixture({ pskIdentity: 'id', pskKey: 'KEY', adm: '0011' });
|
||||
assert.ok(cardsScp80Complete(full));
|
||||
assert.ok(cardsScp81Complete(full));
|
||||
assert.ok(cardsAdmPresent(full));
|
||||
// a complete keyset is required
|
||||
assert.ok(!cardsScp80Complete(Object.assign({}, full, { keysets: [] })));
|
||||
assert.ok(!cardsScp80Complete(Object.assign({}, full, {
|
||||
keysets: [{ kic: '15', kid: '15', kicKey: '', kidKey: 'BB', cntr: '0000000001' }] })));
|
||||
// every role entry needs its TAR and MSL
|
||||
for (const role of ['isd', 'uiccRfm', 'usimRfm']) {
|
||||
const noMsl = presetFixture({ tars: full.tars.map(e =>
|
||||
e.role === role ? Object.assign({}, e, { msl: '' }) : e) });
|
||||
assert.ok(!cardsScp80Complete(noMsl), 'SCP80 complete with an empty MSL for ' + role);
|
||||
const noTar = presetFixture({ tars: full.tars.map(e =>
|
||||
e.role === role ? Object.assign({}, e, { tar: '' }) : e) });
|
||||
assert.ok(!cardsScp80Complete(noTar), 'SCP80 complete with an empty TAR for ' + role);
|
||||
}
|
||||
assert.ok(!cardsScp81Complete({ pskIdentity: 'id' }));
|
||||
assert.ok(!cardsScp81Complete({ pskKey: 'KEY' }));
|
||||
assert.ok(!cardsScp80Complete(null));
|
||||
assert.ok(!cardsScp81Complete(null));
|
||||
assert.ok(!cardsAdmPresent(null));
|
||||
assert.ok(!cardsAdmPresent({ adm: ' ' }));
|
||||
});
|
||||
|
||||
test('clearing the card form keeps the role TAR/MSL defaults', () => {
|
||||
const els = setup();
|
||||
els['cards-pin1'].value = '1234';
|
||||
els['cards-adm'].value = 'DEAD';
|
||||
els['cards-name'].value = 'X';
|
||||
// a free row from a previous edit
|
||||
tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'applet' });
|
||||
_cardsTarCount = 1;
|
||||
cardsClearForm();
|
||||
assert.strictEqual(els['cards-pin1'].value, '');
|
||||
assert.strictEqual(els['cards-adm'].value, '');
|
||||
assert.strictEqual(els['cards-name'].value, '');
|
||||
assert.strictEqual(_cardsTarCount, 0, 'the free TAR rows are cleared');
|
||||
// the role rows render the pre-settled defaults
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000000"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-msl"[^>]*value="16"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B00000"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B00001"/);
|
||||
// ... and the table reads back as the three role entries
|
||||
assert.deepStrictEqual(cardsTarsFromForm(), [
|
||||
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: null },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('editing prefills the code fields and renders the TAR table', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [
|
||||
{ name: 'Legacy', keysets: [] },
|
||||
presetFixture({ name: 'Full', adm: '0011', pin1: '1234', puk1: '12345678',
|
||||
pin2: '0000', puk2: '87654321', tars: [
|
||||
{ role: 'isd', tar: '000001', msl: '0A' },
|
||||
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B0000D', msl: '16' },
|
||||
{ tar: 'AF4D02', msl: '1A', desc: 'applet' }] }),
|
||||
];
|
||||
cardsEdit(0);
|
||||
// the editor opens; missing codes read as empty
|
||||
assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
|
||||
assert.ok(els['cards-list-view'].classList.contains('hidden'));
|
||||
assert.strictEqual(els['cards-pin1'].value, '');
|
||||
assert.strictEqual(els['cards-adm'].value, '');
|
||||
cardsEdit(1);
|
||||
assert.strictEqual(els['cards-pin1'].value, '1234');
|
||||
assert.strictEqual(els['cards-puk1'].value, '12345678');
|
||||
assert.strictEqual(els['cards-pin2'].value, '0000');
|
||||
assert.strictEqual(els['cards-puk2'].value, '87654321');
|
||||
assert.strictEqual(els['cards-adm'].value, '0011');
|
||||
assert.strictEqual(_cardsTarCount, 1);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value="000001"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-1-tar"[^>]*value="B0000C"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-2-tar"[^>]*value="B0000D"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D02"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-desc"[^>]*value="applet"/);
|
||||
// the role rows carry the fixed descriptions and no remove button
|
||||
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
|
||||
assert.match(els['cards-tars'].innerHTML, /UICC shared FS RFM compact format/);
|
||||
assert.match(els['cards-tars'].innerHTML, /ADF FS RFM compact format/);
|
||||
assert.doesNotMatch(els['cards-tars'].innerHTML, /cardsTarRemove\(0\)/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(3\)/);
|
||||
});
|
||||
|
||||
test('the form values carry the codes and the TAR table', () => {
|
||||
const els = setup();
|
||||
els['cards-pin1'].value = ' 12 34 ';
|
||||
els['cards-puk1'].value = '12345678';
|
||||
els['cards-adm'].value = ' 00 11 22 33 ';
|
||||
tarRow(els, 0, { tar: ' 000001 ', msl: '0a' });
|
||||
tarRow(els, 1, { tar: '', msl: '' }); // uiccRfm -> spec default
|
||||
tarRow(els, 2, { tar: 'B00003', msl: '1A' });
|
||||
tarRow(els, 3, { tar: 'af4d02', msl: '0A', desc: 'My applet' });
|
||||
_cardsTarCount = 1;
|
||||
const v = cardsFormValues();
|
||||
assert.deepStrictEqual(v.tars, [
|
||||
{ role: 'isd', tar: '000001', msl: '0A', desc: '', keyset: null },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: null },
|
||||
{ role: 'usimRfm', tar: 'B00003', msl: '1A', desc: '', keyset: null },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'My applet', keyset: null },
|
||||
]);
|
||||
assert.strictEqual(v.pin1, '1234');
|
||||
assert.strictEqual(v.puk1, '12345678');
|
||||
assert.strictEqual(v.adm, '00112233');
|
||||
});
|
||||
|
||||
test('the release field is collected, cleared and restored', () => {
|
||||
const els = setup();
|
||||
els['cards-release'].value = '14';
|
||||
assert.strictEqual(cardsFormValues().release, '14');
|
||||
cardsClearForm();
|
||||
assert.strictEqual(els['cards-release'].value, '');
|
||||
globalThis.cards = [presetFixture({ name: 'Rel', release: '17' })];
|
||||
cardsEdit(0);
|
||||
assert.strictEqual(els['cards-release'].value, '17');
|
||||
// a preset without a release reads as unspecified
|
||||
globalThis.cards = [presetFixture({ name: 'Plain' })];
|
||||
cardsEdit(0);
|
||||
assert.strictEqual(els['cards-release'].value, '');
|
||||
});
|
||||
|
||||
test('saving a preset posts the form with the pre-settled TAR table', async () => {
|
||||
const els = setup();
|
||||
els['cards-name'].value = 'New card';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
// the editor's render preselects the only keyset on every TAR row
|
||||
// (v3.26.0); the fake DOM is static, so the test does it explicitly
|
||||
tarRow(els, 0, { keyset: 1 });
|
||||
tarRow(els, 1, { keyset: 1 });
|
||||
tarRow(els, 2, { keyset: 1 });
|
||||
// the role fields are pre-settled by the markup; the test's fake elements
|
||||
// start empty - the defaults fill in
|
||||
els['cards-adm'].value = 'aa bb';
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 1, JSON.stringify(apiCalls));
|
||||
assert.strictEqual(apiCalls[0].path, '/api/presets');
|
||||
assert.strictEqual(apiCalls[0].body.name, 'New card');
|
||||
assert.deepStrictEqual(apiCalls[0].body.keysets, [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }]);
|
||||
assert.deepStrictEqual(apiCalls[0].body.tars, [
|
||||
{ role: 'isd', tar: '000000', msl: '16', desc: '', keyset: 1 },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16', desc: '', keyset: 1 },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16', desc: '', keyset: 1 },
|
||||
]);
|
||||
assert.strictEqual(apiCalls[0].body.adm, 'AABB');
|
||||
});
|
||||
|
||||
test('saving refuses a free TAR row without its MSL or with a duplicate TAR', async () => {
|
||||
const els = setup();
|
||||
els['cards-name'].value = 'New card';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
const seen = [];
|
||||
globalThis.alert = msg => seen.push(msg);
|
||||
tarRow(els, 3, { tar: 'AF4D02', msl: '', desc: '' });
|
||||
_cardsTarCount = 1;
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /MSL/);
|
||||
// a TAR may appear once
|
||||
seen.length = 0;
|
||||
tarRow(els, 3, { tar: '000000', msl: '16', desc: '' });
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /Duplicate TAR/);
|
||||
});
|
||||
|
||||
test('editing a preset updates it by its store id', async () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture({ id: 'abc123', name: 'Old' })];
|
||||
globalThis._cardsEditIdx = 0;
|
||||
els['cards-name'].value = 'Renamed';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls[0].path, '/api/presets/update');
|
||||
assert.strictEqual(apiCalls[0].body.id, 'abc123');
|
||||
assert.strictEqual(apiCalls[0].body.fields.name, 'Renamed');
|
||||
assert.strictEqual(apiCalls[0].body.fields.tars.length, 3);
|
||||
});
|
||||
|
||||
test('removing a preset deletes it in the store', async () => {
|
||||
setup();
|
||||
globalThis.cards = [{ id: 'abc123', name: 'A' }, { id: 'def456', name: 'B' }];
|
||||
await cardsRemove(0);
|
||||
assert.strictEqual(apiCalls.length, 1);
|
||||
assert.strictEqual(apiCalls[0].path, '/api/presets/delete');
|
||||
assert.strictEqual(apiCalls[0].body.id, 'abc123');
|
||||
});
|
||||
|
||||
test('import posts the entries to the server store', async () => {
|
||||
setup();
|
||||
await cardsImport(JSON.stringify([
|
||||
{ name: 'Imported', kic: '15', kid: '15', adm: ' aa bb ' },
|
||||
{ name: 'Explicit', tar: '000009' },
|
||||
]));
|
||||
assert.strictEqual(apiCalls.length, 1);
|
||||
assert.strictEqual(apiCalls[0].path, '/api/presets/import');
|
||||
assert.strictEqual(apiCalls[0].body.presets.length, 2);
|
||||
assert.strictEqual(apiCalls[0].body.presets[0].name, 'Imported');
|
||||
// a payload that is not an array never reaches the server
|
||||
apiCalls = [];
|
||||
await cardsImport('{"nope": 1}');
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
});
|
||||
|
||||
test('the SCP80 counter field is an override - an edit is never written to the store', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ id: 'abc123', name: 'C', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
|
||||
els['sp-card-sel'].value = '0';
|
||||
els['sp-kic-hex'].value = '15';
|
||||
els['sp-kid-hex'].value = '15';
|
||||
els['sp-cntr'].value = '0000000009'; // an explicit override
|
||||
// typing alone writes nothing (no spCntrSyncPreset anymore, v3.20.0)
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
// a counter the server persisted: the local copy moves forward, display only
|
||||
spCntrLocalSync('000000000A');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
// and the copy never regresses (the store is monotonic)
|
||||
spCntrLocalSync('0000000005');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '000000000A');
|
||||
// the override in the field is untouched by the copy sync
|
||||
assert.strictEqual(els['sp-cntr'].value, '0000000009');
|
||||
});
|
||||
|
||||
test('applying a preset uses the TAR of the current operation and seeds the SPI1 from its MSL', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B0000C', msl: '0A' },
|
||||
{ role: 'usimRfm', tar: 'B0000D', msl: '1A' }] })];
|
||||
globalThis._spTarKey = 'isd';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
assert.strictEqual(els['sp-spi1'].value, '16');
|
||||
globalThis._spTarKey = 'uiccRfm';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000C');
|
||||
assert.strictEqual(els['sp-spi1'].value, '0A');
|
||||
globalThis._spTarKey = 'usimRfm';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000D');
|
||||
assert.strictEqual(els['sp-spi1'].value, '1A');
|
||||
// a preset without TAR entries (hand-made; the store fills the three
|
||||
// roles): the selector has no options and the SPI1 falls back to the
|
||||
// default MSL
|
||||
globalThis.cards = [{ name: 'D', keysets: [], tars: [] }];
|
||||
globalThis._spTarKey = 'isd';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, '');
|
||||
assert.match(els['sp-tar'].innerHTML, /no TAR in the preset/);
|
||||
assert.strictEqual(els['sp-spi1'].value, '16');
|
||||
});
|
||||
|
||||
test('RAM card selection forces the ISD TAR', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture()];
|
||||
globalThis._spTarKey = 'usimRfm';
|
||||
ramApplyCard('0');
|
||||
assert.strictEqual(globalThis._spTarKey, 'isd');
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
});
|
||||
|
||||
test('spTarKeyForPack maps each chain to its preset TAR role', () => {
|
||||
assert.strictEqual(spTarKeyForPack('chain-ram-preview'), 'isd');
|
||||
assert.strictEqual(spTarKeyForPack('chain-sim-preview'), 'uiccRfm');
|
||||
assert.strictEqual(spTarKeyForPack('chain-usim-preview'), 'usimRfm');
|
||||
assert.strictEqual(spTarKeyForPack('ber-result'), null);
|
||||
assert.strictEqual(spTarKeyForPack('hota-preview'), null);
|
||||
});
|
||||
|
||||
test('spPresetTar reads the selected preset and falls back to the spec defaults', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B0000A', msl: '16' }] })];
|
||||
els['sp-card-sel'].value = '0';
|
||||
assert.strictEqual(spPresetTar('uiccRfm'), 'B0000A');
|
||||
assert.strictEqual(spPresetTar('usimRfm'), 'B00001');
|
||||
els['sp-card-sel'].value = '';
|
||||
assert.strictEqual(spPresetTar('uiccRfm'), 'B00000');
|
||||
});
|
||||
|
||||
test('the TAR selector lists the preset table', () => {
|
||||
// v3.26.0: the TAR is a select of the preset's entries (role label or
|
||||
// description, MSL and default keyset in the option text)
|
||||
const els = setup();
|
||||
spTarRender(presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }));
|
||||
assert.match(els['sp-tar'].innerHTML, /value="000000"[^>]*>000000 \u00b7 ISD \u00b7 MSL 16 \u00b7 Keyset 1</);
|
||||
assert.match(els['sp-tar'].innerHTML, /value="AF4D02"[^>]*>AF4D02 \u00b7 My applet \u00b7 MSL 0A</);
|
||||
spTarRender(null);
|
||||
assert.match(els['sp-tar'].innerHTML, /select a card preset/);
|
||||
assert.strictEqual(els['sp-tar'].disabled, true);
|
||||
});
|
||||
|
||||
test('packing a chain selects the TAR of its target', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture({ tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B0000C', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B0000D', msl: '16' }] })];
|
||||
els['sp-card-sel'].value = '0';
|
||||
els['chain-sim-preview'].value = 'A0A4000002';
|
||||
packToSp('chain-sim-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000C');
|
||||
assert.strictEqual(els['sp-apdu'].value, 'A0A4000002');
|
||||
els['chain-usim-preview'].value = '00A4000002';
|
||||
packToSp('chain-usim-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'usimRfm');
|
||||
assert.strictEqual(els['sp-tar'].value, 'B0000D');
|
||||
els['chain-ram-preview'].value = '80E29000';
|
||||
packToSp('chain-ram-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'isd');
|
||||
assert.strictEqual(els['sp-tar'].value, '000000');
|
||||
// BER / HTTP OTA packs leave the TAR alone
|
||||
els['sp-tar'].value = 'ABCDEF';
|
||||
els['ber-result'].value = 'AA0100';
|
||||
packToSp('ber-result');
|
||||
assert.strictEqual(els['sp-tar'].value, 'ABCDEF');
|
||||
assert.strictEqual(els['sp-apdu'].value, 'AA0100');
|
||||
});
|
||||
|
||||
test('packing without a selected preset keeps the role for the next apply', () => {
|
||||
// v3.26.0: the TAR options come from the preset - without one the selector
|
||||
// stays on its placeholder and the role is applied when a preset arrives
|
||||
const els = setup();
|
||||
els['chain-sim-preview'].value = 'A0A4000002';
|
||||
packToSp('chain-sim-preview');
|
||||
assert.strictEqual(globalThis._spTarKey, 'uiccRfm');
|
||||
assert.strictEqual(els['sp-tar'].value, '', 'no preset -> no TAR options yet');
|
||||
globalThis.cards = [presetFixture()];
|
||||
els['sp-card-sel'].value = '0';
|
||||
cardsApply(0);
|
||||
assert.strictEqual(els['sp-tar'].value, 'B00000');
|
||||
});
|
||||
|
||||
test('the TAR row values are left-aligned like the headers', () => {
|
||||
const out = cardsTarRowHtml({ tar: 'AF4D01', msl: '0A', desc: 'x' }, 3);
|
||||
assert.doesNotMatch(out, /text-center/);
|
||||
assert.match(out, /<td class="py-1 px-2"><input id="cards-tr-3-tar" class="w-full/);
|
||||
assert.match(out, /<td class="py-1 px-2"><input id="cards-tr-3-msl" class="w-full/);
|
||||
});
|
||||
|
||||
test('the keyset row renders as a table row with full-width inputs', () => {
|
||||
const out = cardsKeysetRowHtml({ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }, 0);
|
||||
assert.match(out, /^<tr>/);
|
||||
assert.match(out, /<td class="py-1 px-2"><span id="cards-ks-0-kvn"/);
|
||||
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-kic" class="w-full/);
|
||||
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-kic-key" class="w-full/);
|
||||
assert.match(out, /<td class="py-1 px-2"><input id="cards-ks-0-cntr" class="w-full/);
|
||||
assert.match(out, /cardsKeysetRemove\(0\)/);
|
||||
});
|
||||
|
||||
test('the TAR table renders the role rows and adds/removes free rows', () => {
|
||||
const els = setup();
|
||||
cardsTarRowsRender([{ tar: 'AF4D01', msl: '0A', desc: 'one' },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'two' }]);
|
||||
assert.strictEqual(_cardsTarCount, 2);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value=""/);
|
||||
assert.match(els['cards-tars'].innerHTML, /ISD compact format/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-3-tar"[^>]*value="AF4D01"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-tar"[^>]*value="AF4D02"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-4-desc"[^>]*value="two"/);
|
||||
assert.match(els['cards-tars'].innerHTML, /cardsTarRemove\(4\)/);
|
||||
// simulate the DOM the innerHTML produced
|
||||
tarRow(els, 3, { tar: 'AF4D01', msl: '0A', desc: 'one' });
|
||||
tarRow(els, 4, { tar: 'AF4D02', msl: '0A', desc: 'two' });
|
||||
cardsTarRemove(3);
|
||||
assert.strictEqual(_cardsTarCount, 1);
|
||||
assert.match(els['cards-tars'].innerHTML, /value="AF4D02"/);
|
||||
assert.doesNotMatch(els['cards-tars'].innerHTML, /value="AF4D01"/);
|
||||
tarRow(els, 3, { tar: 'AF4D02', msl: '0A', desc: 'two' });
|
||||
cardsTarAdd();
|
||||
assert.strictEqual(_cardsTarCount, 2);
|
||||
// the new row renders empty
|
||||
tarRow(els, 4, { tar: '', msl: '', desc: '' });
|
||||
assert.deepStrictEqual(cardsFreeTarsFromForm(),
|
||||
[{ tar: 'AF4D02', msl: '0A', desc: 'two', keyset: null }]);
|
||||
// the role rows are never removable
|
||||
cardsTarRemove(0);
|
||||
assert.strictEqual(_cardsTarCount, 2);
|
||||
// an emptied role field survives a re-render (no silent default refill),
|
||||
// while the save path still fills the default
|
||||
tarRow(els, 0, { tar: '', msl: '' });
|
||||
cardsTarRemove(3);
|
||||
assert.strictEqual(_cardsTarCount, 0);
|
||||
assert.match(els['cards-tars'].innerHTML, /cards-tr-0-tar"[^>]*value=""/);
|
||||
assert.strictEqual(cardsTarsFromForm()[0].tar, '000000');
|
||||
assert.strictEqual(cardsTarsFromForm()[0].msl, '16');
|
||||
});
|
||||
|
||||
test('the list view opens the editor for a new preset and Cancel returns', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture()];
|
||||
cardsNew();
|
||||
assert.ok(!els['cards-editor-view'].classList.contains('hidden'), 'the editor is shown');
|
||||
assert.ok(els['cards-list-view'].classList.contains('hidden'), 'the list is hidden');
|
||||
assert.strictEqual(globalThis._cardsEditIdx, null);
|
||||
assert.strictEqual(els['cards-editor-title'].textContent, 'Add preset');
|
||||
assert.strictEqual(els['cards-add-btn'].textContent, 'Add');
|
||||
assert.strictEqual(_cardsTarCount, 0);
|
||||
cardsCancelEdit();
|
||||
assert.ok(!els['cards-list-view'].classList.contains('hidden'), 'the list is back');
|
||||
assert.ok(els['cards-editor-view'].classList.contains('hidden'), 'the editor is hidden');
|
||||
});
|
||||
|
||||
test('editing opens the editor with the preset title', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [presetFixture({ name: 'Foo' })];
|
||||
cardsEdit(0);
|
||||
assert.strictEqual(globalThis._cardsEditIdx, 0);
|
||||
assert.ok(!els['cards-editor-view'].classList.contains('hidden'));
|
||||
assert.strictEqual(els['cards-editor-title'].textContent, 'Edit preset: Foo');
|
||||
assert.strictEqual(els['cards-add-btn'].textContent, 'Save');
|
||||
});
|
||||
|
||||
test('the editor refuses PIN/PUK codes that are not 4-8 digits', async () => {
|
||||
const els = setup();
|
||||
els['cards-name'].value = 'X';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
const seen = [];
|
||||
globalThis.alert = msg => seen.push(msg);
|
||||
els['cards-pin1'].value = '123';
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /PIN\/PUK/);
|
||||
seen.length = 0;
|
||||
els['cards-pin1'].value = '1234';
|
||||
els['cards-puk2'].value = '12A4';
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /PUK2/);
|
||||
});
|
||||
|
||||
test('the editor refuses a counter that is not 1-10 hex digits', async () => {
|
||||
const els = setup();
|
||||
els['cards-name'].value = 'X';
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: 'XYZ' });
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
const seen = [];
|
||||
globalThis.alert = msg => seen.push(msg);
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /Counter must be 1-10 hex digits/);
|
||||
// a valid value - spaces included, the server strips them - is posted
|
||||
seen.length = 0;
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '00 00 00 00 01' });
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 1, JSON.stringify(seen));
|
||||
assert.strictEqual(apiCalls[0].body.keysets[0].cntr, '00 00 00 00 01');
|
||||
// an empty counter is refused too (the value is required)
|
||||
seen.length = 0;
|
||||
apiCalls.length = 0;
|
||||
els['cards-name'].value = 'X';
|
||||
globalThis._cardsKeysetCount = 1;
|
||||
ksRow(els, 0, { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '' });
|
||||
await cardsAdd();
|
||||
assert.strictEqual(apiCalls.length, 0);
|
||||
assert.match(seen[0], /Counter must be 1-10 hex digits/);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
|
||||
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
|
||||
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
|
||||
'esc', 'spKeysetList', 'spKeysetKvnOf',
|
||||
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar',
|
||||
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'cardsScp81Complete']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||||
+ extractConst(html, 'CARDS_MSL_DEFAULT');
|
||||
eval(code);
|
||||
// The dialog sweep replaced the native alert/confirm with the helpers: route
|
||||
// them to the native stubs each test installs on globalThis.
|
||||
globalThis.pysimAlert = m => globalThis.alert(m);
|
||||
globalThis.pysimConfirm = async m => globalThis.confirm(m);
|
||||
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
|
||||
|
||||
// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad)
|
||||
const RAW_HEX = '980711090000640090F8';
|
||||
const DIGITS = '8970119000004600098';
|
||||
|
||||
function fakeDoc() {
|
||||
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' },
|
||||
'tar-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
function fakeAlert() {
|
||||
const seen = [];
|
||||
globalThis.alert = msg => seen.push(msg);
|
||||
globalThis.t = s => s;
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('esc escapes quotes as well as the angle brackets', () => {
|
||||
// the output is used inside attribute values (value="…", title="…",
|
||||
// label="…") as well as in text contexts (v3.10.0 review fix)
|
||||
assert.strictEqual(esc('a"b\'c<d>&'), 'a"b'c<d>&');
|
||||
});
|
||||
|
||||
test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
|
||||
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(RAW_HEX), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(RAW_HEX.toLowerCase()), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(''), '');
|
||||
assert.strictEqual(cardsNormIccid(null), '');
|
||||
});
|
||||
|
||||
test('cardsNormIccid accepts the outfile digits+F form (v3.27.0)', () => {
|
||||
// the vendors' outfiles print the ICCID as digits + an odd-nibble F pad -
|
||||
// not the raw EF form (which never ends in F)
|
||||
assert.strictEqual(cardsNormIccid(DIGITS + 'F'), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(DIGITS + 'FF'), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid(DIGITS + 'f'), DIGITS);
|
||||
assert.strictEqual(cardsNormIccid('FFFF'), '');
|
||||
});
|
||||
|
||||
test('cardsFindByIccid matches regardless of the stored form', () => {
|
||||
globalThis.cards = [
|
||||
{ name: 'A', iccid: '1111111111111111111' },
|
||||
{ name: 'B', iccid: RAW_HEX },
|
||||
{ name: 'C', iccid: '89 70 1190 0000 4600 098' },
|
||||
];
|
||||
assert.strictEqual(cardsFindByIccid(DIGITS), 1);
|
||||
assert.strictEqual(cardsFindByIccid('1111111111111111111'), 0);
|
||||
assert.strictEqual(cardsFindByIccid('2222222222222222222'), -1);
|
||||
assert.strictEqual(cardsFindByIccid(''), -1);
|
||||
});
|
||||
|
||||
test('cardsAutoSelectByIccid selects the preset in both SCP80 views', () => {
|
||||
const els = fakeDoc();
|
||||
const applied = [];
|
||||
globalThis.cardsApply = idx => applied.push(idx);
|
||||
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }, { name: 'B', iccid: '' }];
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsAutoIccid = null;
|
||||
const idx = cardsAutoSelectByIccid(DIGITS);
|
||||
assert.strictEqual(idx, 0);
|
||||
assert.strictEqual(els['sp-card-sel'].value, '0');
|
||||
assert.strictEqual(els['ram-card-sel'].value, '0');
|
||||
assert.strictEqual(els['tar-card-sel'].value, '0',
|
||||
'the TAR probe view gets the equipped card\'s preset too');
|
||||
assert.deepStrictEqual(applied, ['0']);
|
||||
assert.strictEqual(globalThis._ramCardIdx, 0);
|
||||
});
|
||||
|
||||
test('cardsAutoSelectByIccid does not override a manual choice for the same card', () => {
|
||||
const els = fakeDoc();
|
||||
const applied = [];
|
||||
globalThis.cardsApply = idx => applied.push(idx);
|
||||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
|
||||
globalThis._ramCardIdx = null;
|
||||
globalThis._cardsAutoIccid = null;
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||||
// user picks the empty preset B for this card, then status polls repeat
|
||||
els['sp-card-sel'].value = '1';
|
||||
els['ram-card-sel'].value = '1';
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
|
||||
assert.strictEqual(els['sp-card-sel'].value, '1');
|
||||
assert.deepStrictEqual(applied, ['0']);
|
||||
});
|
||||
|
||||
test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', () => {
|
||||
fakeDoc();
|
||||
globalThis.cardsApply = () => {};
|
||||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '89390100000129506903' }];
|
||||
globalThis._cardsAutoIccid = null;
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||||
assert.strictEqual(cardsAutoSelectByIccid('89390100000129506903'), 1);
|
||||
// ICCID not readable: reset the guard, keep the current selection
|
||||
assert.strictEqual(cardsAutoSelectByIccid(null), -1);
|
||||
assert.strictEqual(globalThis._cardsAutoIccid, null);
|
||||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||||
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
|
||||
});
|
||||
|
||||
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
|
||||
globalThis.cards = [
|
||||
{ name: 'A', iccid: '1111111111111111111' },
|
||||
{ name: 'B', iccid: RAW_HEX },
|
||||
{ name: 'C', iccid: '' },
|
||||
];
|
||||
assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1);
|
||||
assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1);
|
||||
assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0);
|
||||
assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1);
|
||||
// empty ICCID is never a duplicate (the field is optional)
|
||||
assert.strictEqual(cardsFindDuplicateIccid(''), -1);
|
||||
assert.strictEqual(cardsFindDuplicateIccid(' '), -1);
|
||||
});
|
||||
|
||||
test('cardsFindDuplicateIccid skips the row being edited', () => {
|
||||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
|
||||
assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1);
|
||||
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1);
|
||||
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0);
|
||||
});
|
||||
|
||||
test('cardsIccidFromCard fills the field from the equipped card', () => {
|
||||
const els = fakeDoc();
|
||||
const seen = fakeAlert();
|
||||
globalThis._pysimCardIccid = DIGITS;
|
||||
cardsIccidFromCard();
|
||||
assert.strictEqual(els['cards-iccid'].value, DIGITS);
|
||||
assert.deepStrictEqual(seen, []);
|
||||
// no readable ICCID (state raced the click): alert, leave the field alone
|
||||
els['cards-iccid'].value = 'keep-me';
|
||||
globalThis._pysimCardIccid = null;
|
||||
cardsIccidFromCard();
|
||||
assert.strictEqual(els['cards-iccid'].value, 'keep-me');
|
||||
assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']);
|
||||
});
|
||||
|
||||
test('the From card button and the duplicate refusal are wired into the form', () => {
|
||||
assert.match(html, /<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard\(\)"/);
|
||||
assert.ok(html.includes('data-l10n="From card"'));
|
||||
// normalized duplicate check with the conflicting preset named
|
||||
assert.ok(html.includes('cardsFindDuplicateIccid(v.iccid, _cardsEditIdx)'));
|
||||
assert.ok(html.includes("t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name"));
|
||||
assert.ok(html.includes("'From card': 'С карты'"));
|
||||
assert.ok(html.includes("'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке'"));
|
||||
});
|
||||
|
||||
test('the card-state update wires the ICCID into the preset selection', () => {
|
||||
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
|
||||
assert.ok(html.includes('_cardsAutoIccid = null;'));
|
||||
assert.ok(html.includes("getElementById('tar-card-sel')"),
|
||||
'the auto-selection must cover the TAR probe view');
|
||||
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
|
||||
});
|
||||
|
||||
test('cardsRender highlights the equipped card preset', () => {
|
||||
globalThis.t = s => s;
|
||||
const els = { 'cards-tbody': { innerHTML: '' } };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [
|
||||
{ name: 'other', iccid: '1111111111111111111', keysets: [] },
|
||||
{ name: 'live', iccid: DIGITS, keysets: [] },
|
||||
];
|
||||
globalThis._pysimCardIccid = DIGITS;
|
||||
cardsRender();
|
||||
let rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||||
assert.ok(!/bg-emerald-50/.test(rows[0]), 'the other preset must stay plain');
|
||||
assert.match(rows[1], /bg-emerald-50/);
|
||||
assert.match(rows[1], /The equipped card's preset/);
|
||||
// the same card stored as raw EF hex matches too
|
||||
globalThis.cards[1].iccid = RAW_HEX;
|
||||
cardsRender();
|
||||
rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||||
assert.match(rows[1], /bg-emerald-50/);
|
||||
// no readable ICCID -> no highlight
|
||||
globalThis._pysimCardIccid = null;
|
||||
cardsRender();
|
||||
assert.ok(!/bg-emerald-50/.test(els['cards-tbody'].innerHTML));
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImport'];
|
||||
let code = '';
|
||||
for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm',
|
||||
'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd',
|
||||
'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysetOptions',
|
||||
'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose',
|
||||
'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview',
|
||||
'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) {
|
||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||
}
|
||||
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||||
+ extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC')
|
||||
+ 'var _cardsOutfile = null;\nvar _cardsOutfileTars = 0;\n' + code;
|
||||
|
||||
// A minimal DOM: the tests set the elements they need, everything else is
|
||||
// absent (the code guards every getElementById result).
|
||||
const els = {};
|
||||
const app = new Function('els', `
|
||||
var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null, confirmResult: true };
|
||||
var document = { getElementById: id => els[id] || null };
|
||||
var t = k => k;
|
||||
var alert = m => { sandbox.alerts.push(String(m)); };
|
||||
var confirm = m => { sandbox.confirmCalls = (sandbox.confirmCalls || []); sandbox.confirmCalls.push(String(m)); return sandbox.confirmResult; };
|
||||
var pysimAlert = m => alert(m);
|
||||
var pysimConfirm = async m => confirm(m);
|
||||
var pysimPrompt = async () => ''; // not used by the outfile flow
|
||||
var ioStatus = (id, msg) => { sandbox.io.push([id, msg]); };
|
||||
var pysimFetchOk = async (path, body) => {
|
||||
sandbox.calls.push({ path: path, body: body });
|
||||
if (sandbox.replyError) throw new Error(sandbox.replyError);
|
||||
return sandbox.reply;
|
||||
};
|
||||
var cardsFetch = async () => { sandbox.fetched++; };
|
||||
${code}
|
||||
sandbox.getState = () => _cardsOutfile;
|
||||
sandbox.setState = v => { _cardsOutfile = v; };
|
||||
sandbox.getTars = () => _cardsOutfileTars;
|
||||
sandbox.setTars = v => { _cardsOutfileTars = v; };
|
||||
Object.defineProperty(sandbox, 'state', { get: () => _cardsOutfile, set: v => { _cardsOutfile = v; } });
|
||||
Object.defineProperty(sandbox, 'tars', { get: () => _cardsOutfileTars, set: v => { _cardsOutfileTars = v; } });
|
||||
sandbox.cardsOutfileTarsFromDom = cardsOutfileTarsFromDom;
|
||||
sandbox.cardsOutfileTarsFromForm = cardsOutfileTarsFromForm;
|
||||
sandbox.cardsOutfileTarRowHtml = cardsOutfileTarRowHtml;
|
||||
sandbox.cardsOutfileTarRowsHtml = cardsOutfileTarRowsHtml;
|
||||
sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd;
|
||||
sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove;
|
||||
sandbox.cardsOutfileTarRender = cardsOutfileTarRender;
|
||||
sandbox.cardsOutfileKeysetOptions = cardsOutfileKeysetOptions;
|
||||
sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml;
|
||||
sandbox.cardsOutfileControls = cardsOutfileControls;
|
||||
sandbox.cardsOutfileClose = cardsOutfileClose;
|
||||
sandbox.cardsOutfileRender = cardsOutfileRender;
|
||||
sandbox.cardsOutfileFile = cardsOutfileFile;
|
||||
sandbox.cardsOutfilePreview = cardsOutfilePreview;
|
||||
sandbox.cardsOutfileImport = cardsOutfileImport;
|
||||
return sandbox;
|
||||
`)(els);
|
||||
app.state = null;
|
||||
app.tars = 0;
|
||||
|
||||
function setEl(id, value) {
|
||||
els[id] = { value: value, innerHTML: '', classList: { add() {}, remove() {} } };
|
||||
}
|
||||
function clearEls() {
|
||||
for (const k of Object.keys(els)) delete els[k];
|
||||
}
|
||||
|
||||
const TEXT = 'var_out:IMSI ICCID KIC1 KID1\n250197700000000 8970119000004600098F ' +
|
||||
'00112233445566778899AABBCCDDEEFF FFEEDDCCBBAA99887766554433221100\n';
|
||||
|
||||
test('the Cards view wires the outfile button, the input and the panel', () => {
|
||||
assert.match(html, /onclick="cardsOutfilePick\(\)"[^>]*data-l10n="Import outfile"/);
|
||||
assert.match(html, /<input type="file" id="cards-outfile"[^>]*onchange="cardsOutfileFile\(this\)"/);
|
||||
assert.match(html, /<div id="cards-outfile-panel"/);
|
||||
assert.match(html, /'Import outfile': 'Импорт outfile'/); // the term stays untranslated
|
||||
});
|
||||
|
||||
test('the preview and the import both post the outfile endpoint', () => {
|
||||
const preview = extractFunc(html, 'cardsOutfilePreview', true);
|
||||
const imp = extractFunc(html, 'cardsOutfileImport', true);
|
||||
assert.match(preview, /'\/api\/presets\/import-outfile'/);
|
||||
assert.match(imp, /'\/api\/presets\/import-outfile'/);
|
||||
assert.match(imp, /mode: o\.mode/);
|
||||
// the keysets travel per TAR row (v3.29.1): no global tar_keyset
|
||||
assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
|
||||
assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
|
||||
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/);
|
||||
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/);
|
||||
});
|
||||
|
||||
test('the 16-byte algorithm values match the server map', () => {
|
||||
const py = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'pysim_simple_server', 'outfile.py'), 'utf8');
|
||||
const m = /_KEY_ALGO_16\s*=\s*\{([^}]*)\}/.exec(py);
|
||||
assert.ok(m, '_KEY_ALGO_16 not found in outfile.py');
|
||||
const server = [...m[1].matchAll(/'([a-z0-9]+)'\s*:/g)].map(x => x[1]).sort();
|
||||
const ui = [...html.matchAll(/<option value="(3des2|aes)"/g)].map(x => x[1]).sort();
|
||||
assert.deepStrictEqual(ui, server);
|
||||
});
|
||||
|
||||
test('cardsOutfileTarsFromForm fills the role defaults and keeps the free rows', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
|
||||
app.tars = 0;
|
||||
const tars = app.cardsOutfileTarsFromForm();
|
||||
assert.deepStrictEqual(tars.map(e => e.role), ['isd', 'uiccRfm', 'usimRfm']);
|
||||
assert.deepStrictEqual(tars.map(e => e.tar), ['000000', 'B00000', 'B00001']);
|
||||
assert.deepStrictEqual(tars.map(e => e.msl), ['16', '16', '16']);
|
||||
// a typed role value wins, a typed free row is kept, an empty one dropped
|
||||
setEl('cards-of-tar-0-tar', 'af4d01');
|
||||
setEl('cards-of-tar-0-msl', '0a');
|
||||
app.tars = 2;
|
||||
setEl('cards-of-tar-3-tar', 'b00011');
|
||||
setEl('cards-of-tar-3-msl', '16');
|
||||
setEl('cards-of-tar-3-desc', 'push');
|
||||
const tars2 = app.cardsOutfileTarsFromForm();
|
||||
assert.strictEqual(tars2.length, 4);
|
||||
assert.deepStrictEqual([tars2[0].tar, tars2[0].msl], ['AF4D01', '0A']);
|
||||
assert.deepStrictEqual([tars2[3].tar, tars2[3].msl, tars2[3].desc], ['B00011', '16', 'push']);
|
||||
assert.strictEqual(tars2[3].role, undefined);
|
||||
});
|
||||
|
||||
test('the TAR rows render the role ids and the free rows with a remove button', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
|
||||
app.tars = 0;
|
||||
const rows = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows, /id="cards-of-tar-0-tar"[^>]*value="000000"/);
|
||||
assert.match(rows, /id="cards-of-tar-2-msl"[^>]*value="16"/);
|
||||
assert.match(rows, /ISD compact format/);
|
||||
assert.ok(!rows.includes('cards-of-tar-3-tar'));
|
||||
// a free row carries the description input and the remove action
|
||||
app.tars = 1;
|
||||
const rows2 = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows2, /id="cards-of-tar-3-tar"/);
|
||||
assert.match(rows2, /id="cards-of-tar-3-desc"/);
|
||||
assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/);
|
||||
// every row has a keyset select, fed by the parsed keysets (v3.29.1)
|
||||
assert.match(rows2, /id="cards-of-tar-0-keyset"/);
|
||||
assert.match(rows2, /id="cards-of-tar-3-keyset"/);
|
||||
app.state.preview = { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] };
|
||||
const rows3 = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows3, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
|
||||
assert.match(rows3, /<option value="2">Keyset 2 — 25\/25<\/option>/);
|
||||
// a non-zero MSL without a keyset gets the amber marker
|
||||
app.state.preview = { presets: [] };
|
||||
const rows4 = app.cardsOutfileTarRowsHtml();
|
||||
assert.match(rows4, /This TAR needs a keyset/);
|
||||
});
|
||||
|
||||
test('cardsOutfileKeysetOptions collects the parsed keysets with their bytes', () => {
|
||||
clearEls();
|
||||
app.state = {
|
||||
text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
|
||||
preview: { presets: [
|
||||
{ keysets: [{ kic: '25', kid: '25' }, { kic: '15', kid: '15' }] },
|
||||
{ keysets: [{ kic: '15', kid: '15' }, { kic: '31', kid: '31' }] },
|
||||
] },
|
||||
};
|
||||
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(),
|
||||
[{ v: 1, kic: '15', kid: '15' }, { v: 2, kic: '25', kid: '25' },
|
||||
{ v: 3, kic: '31', kid: '31' }]);
|
||||
app.state.preview = { presets: [] };
|
||||
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(), []);
|
||||
});
|
||||
|
||||
test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
|
||||
clearEls();
|
||||
app.state = {
|
||||
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
|
||||
preview: {
|
||||
presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }], adm: '62329F37' }],
|
||||
report: { columns: ['IMSI', 'ICCID', 'KIC1', 'KID1'], rows: 2, cards: 1,
|
||||
keysets: ['KIC1/KID1'], skipped: ['line 3: no ICCID'], skipped_more: 1 },
|
||||
},
|
||||
};
|
||||
const out = app.cardsOutfileResultHtml();
|
||||
assert.match(out, /Columns: <span class="font-mono">IMSI ICCID KIC1 KID1<\/span>/);
|
||||
assert.match(out, /Cards: 1 \/ rows read: 2/);
|
||||
assert.match(out, /Keysets: <span class="font-mono">KIC1\/KID1<\/span>/);
|
||||
assert.match(out, /line 3: no ICCID/);
|
||||
assert.match(out, /\(\+1\)/);
|
||||
assert.match(out, /8970119000004600098/);
|
||||
assert.match(out, /15\/15/);
|
||||
assert.match(out, /ADM/);
|
||||
// an empty preview says so, an error is shown
|
||||
app.state.preview = null;
|
||||
assert.match(app.cardsOutfileResultHtml(), /Parsing\.\.\./);
|
||||
app.state.preview = { report: { error: 'no column-name line found' } };
|
||||
assert.match(app.cardsOutfileResultHtml(), /no column-name line found/);
|
||||
});
|
||||
|
||||
test('cardsOutfileControls reads the panel controls back into the state', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, name: 'old', algorithm16: '3des2', mode: 'merge', preview: null };
|
||||
setEl('cards-of-name', ' Batch 7 ');
|
||||
setEl('cards-of-algo', 'aes');
|
||||
setEl('cards-of-mode', 'replace');
|
||||
app.cardsOutfileControls();
|
||||
assert.strictEqual(app.state.name, 'Batch 7');
|
||||
assert.strictEqual(app.state.algorithm16, 'aes');
|
||||
assert.strictEqual(app.state.mode, 'replace');
|
||||
});
|
||||
|
||||
test('picking a file previews it with the file name and the TAR defaults', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.reply = { preview: true, presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
|
||||
report: { columns: ['ICCID'], rows: 1, cards: 1 } };
|
||||
setEl('cards-outfile-panel', '');
|
||||
const input = { value: 'C:\\fakepath\\batch.out', files: [{ name: 'batch.out', text: async () => TEXT }] };
|
||||
await app.cardsOutfileFile(input);
|
||||
assert.strictEqual(input.value, '');
|
||||
assert.strictEqual(app.calls.length, 1);
|
||||
assert.strictEqual(app.calls[0].path, '/api/presets/import-outfile');
|
||||
const body = app.calls[0].body;
|
||||
assert.strictEqual(body.text, TEXT);
|
||||
assert.strictEqual(body.name, 'batch'); // the file base name
|
||||
assert.strictEqual(body.algorithm16, '3des2');
|
||||
assert.strictEqual(body.mode, undefined); // the preview is a dry run
|
||||
assert.strictEqual(body.tar_keyset, undefined); // the keysets are per row now
|
||||
assert.strictEqual(body.tars.length, 3);
|
||||
// the rows' keyset selects are filled from the parsed keysets (the first one
|
||||
// is the default)
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
|
||||
});
|
||||
|
||||
test('a failing preview shows the server message in the panel', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.replyError = 'TAR must be three hex bytes: ZZ';
|
||||
setEl('cards-outfile-panel', '');
|
||||
const input = { value: '', files: [{ name: 'bad.out', text: async () => TEXT }] };
|
||||
await app.cardsOutfileFile(input);
|
||||
app.replyError = null;
|
||||
assert.match(app.cardsOutfileResultHtml(), /TAR must be three hex bytes/);
|
||||
});
|
||||
|
||||
test('the import posts the chosen mode and refreshes the list', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.fetched = 0;
|
||||
app.io = [];
|
||||
app.confirmResult = true;
|
||||
app.reply = { ok: true, preview: false, added: 2, skipped: 1, errors: ['dup'] };
|
||||
setEl('cards-outfile-panel', '');
|
||||
setEl('cards-of-mode', 'replace');
|
||||
setEl('cards-of-name', 'Batch 7');
|
||||
app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2',
|
||||
mode: 'merge', preview: { presets: [], report: {} } };
|
||||
setEl('cards-of-tar-0-keyset', '2'); // the ISD row's chosen keyset
|
||||
await app.cardsOutfileImport();
|
||||
assert.strictEqual(app.calls.length, 1);
|
||||
assert.strictEqual(app.calls[0].body.mode, 'replace');
|
||||
assert.strictEqual(app.calls[0].body.tar_keyset, undefined); // per row, not global
|
||||
assert.strictEqual(app.calls[0].body.tars[0].keyset, 2);
|
||||
assert.strictEqual(app.calls[0].body.name, 'Batch 7');
|
||||
assert.strictEqual(app.fetched, 1);
|
||||
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
|
||||
assert.strictEqual(app.state, null); // the panel closed
|
||||
// the replace mode asks first, and a declined answer imports nothing
|
||||
assert.deepStrictEqual(app.confirmCalls, ['Replace mode wipes the whole preset store. Continue?']);
|
||||
});
|
||||
|
||||
test('a declined replace confirmation aborts the import', async () => {
|
||||
clearEls();
|
||||
app.calls = [];
|
||||
app.confirmResult = false;
|
||||
setEl('cards-of-mode', 'replace');
|
||||
app.state = { text: TEXT, file: 'batch.out', name: 'x', algorithm16: '3des2',
|
||||
tarKeyset: null, mode: 'merge', preview: { presets: [], report: {} } };
|
||||
await app.cardsOutfileImport();
|
||||
app.confirmResult = true;
|
||||
assert.strictEqual(app.calls.length, 0);
|
||||
assert.ok(app.state); // the panel stays open
|
||||
// merge never asks
|
||||
app.confirmResult = false;
|
||||
setEl('cards-of-mode', 'merge');
|
||||
app.reply = { ok: true, preview: false, added: 1, skipped: 0, errors: [] };
|
||||
await app.cardsOutfileImport();
|
||||
app.confirmResult = true;
|
||||
assert.strictEqual(app.calls.length, 1);
|
||||
});
|
||||
|
||||
test('the Import button is disabled while the report has a file error (v3.29.2)', () => {
|
||||
clearEls();
|
||||
setEl('cards-outfile-panel', '');
|
||||
app.state = { text: TEXT, file: 'bad.out', name: 'x', algorithm16: '3des2', mode: 'merge',
|
||||
preview: { presets: [], report: { error: 'line 2 has 2 values, the header declares 3 columns - the file is invalid' } } };
|
||||
app.tars = 0;
|
||||
app.cardsOutfileRender();
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" disabled/);
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /the header declares 3 columns/);
|
||||
// a valid report keeps it enabled
|
||||
app.state.preview = { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
|
||||
report: { columns: ['ICCID', 'KIC1', 'KID1'], rows: 1, cards: 1 } };
|
||||
app.cardsOutfileRender();
|
||||
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" onclick/);
|
||||
});
|
||||
|
||||
test('a row keeps its chosen keyset through the DOM round-trip', () => {
|
||||
clearEls();
|
||||
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
|
||||
preview: { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] } };
|
||||
app.tars = 0;
|
||||
setEl('cards-of-tar-0-keyset', '2'); // the user picked keyset 2 for the ISD row
|
||||
setEl('cards-of-tar-1-keyset', '');
|
||||
const tars = app.cardsOutfileTarsFromForm();
|
||||
assert.strictEqual(tars[0].keyset, 2);
|
||||
assert.strictEqual(tars[1].keyset, null);
|
||||
// a free row carries its own keyset too
|
||||
app.tars = 1;
|
||||
setEl('cards-of-tar-3-tar', 'b00011');
|
||||
setEl('cards-of-tar-3-msl', '1a');
|
||||
setEl('cards-of-tar-3-keyset', '1');
|
||||
const tars2 = app.cardsOutfileTarsFromForm();
|
||||
assert.strictEqual(tars2.length, 4);
|
||||
assert.strictEqual(tars2[3].keyset, 1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const CAT_RELEASE = {', '// The display label of an introduction release')
|
||||
.replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'catReleaseLabel'));
|
||||
eval(extractBlock('const CAT_RELEASE_DEFAULT', 'function presetRelease').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'presetRelease'));
|
||||
eval(extractFunc(html, 'cardsNormIccid'));
|
||||
eval(extractFunc(html, 'catEffectiveReleaseInfo'));
|
||||
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
|
||||
|
||||
test('CAT_RELEASE carries the introduction releases of the event list', () => {
|
||||
assert.strictEqual(CAT_RELEASE.events[0x10], 6);
|
||||
assert.strictEqual(CAT_RELEASE.events[0x12], 8);
|
||||
assert.strictEqual(CAT_RELEASE.events[0x1D], 14);
|
||||
assert.strictEqual(CAT_RELEASE.events[0x1E], 17);
|
||||
assert.strictEqual(CAT_RELEASE.events[0x1F], 16);
|
||||
// the Void 0x1A is not an event and the reserved 0x20+ codes are not
|
||||
// introduced by any release
|
||||
assert.strictEqual(CAT_RELEASE.events[0x1A], undefined);
|
||||
assert.strictEqual(CAT_RELEASE.events[0x20], undefined);
|
||||
});
|
||||
|
||||
test('every named event through 0x1F has an introduction release', () => {
|
||||
const named = Object.keys(EVENT_NAMES).map(Number).filter(c => c <= 0x1F && c !== 0x1A);
|
||||
for (const code of named) {
|
||||
assert.strictEqual(typeof CAT_RELEASE.events[code], 'number', '0x' + code.toString(16));
|
||||
}
|
||||
assert.strictEqual(Object.keys(CAT_RELEASE.events).length, named.length);
|
||||
});
|
||||
|
||||
test('CAT_RELEASE carries commands, objects and PLI qualifiers', () => {
|
||||
assert.strictEqual(CAT_RELEASE.commands['OPEN CHANNEL'], 4);
|
||||
assert.strictEqual(CAT_RELEASE.commands['SET FRAMES'], 6);
|
||||
assert.strictEqual(CAT_RELEASE.commands['GEOGRAPHICAL LOCATION REQUEST'], 8);
|
||||
assert.strictEqual(CAT_RELEASE.commands['ACTIVATE'], 13);
|
||||
assert.strictEqual(CAT_RELEASE.commands['LSI COMMAND'], 17);
|
||||
assert.strictEqual(CAT_RELEASE.objects['67/E7 Frames Information'], 6);
|
||||
assert.strictEqual(CAT_RELEASE.objects['9D Data connection status'], 14);
|
||||
assert.strictEqual(CAT_RELEASE.objects['55/D5 CAG cell selection status'], 17);
|
||||
assert.strictEqual(CAT_RELEASE.pli['08'], 6);
|
||||
assert.strictEqual(CAT_RELEASE.pli['14'], 13);
|
||||
assert.strictEqual(CAT_RELEASE.pli['15'], 16);
|
||||
assert.strictEqual(CAT_RELEASE.pli['16'], 17);
|
||||
});
|
||||
|
||||
test('catReleaseLabel collapses everything below Rel-6', () => {
|
||||
assert.strictEqual(catReleaseLabel(0), '');
|
||||
assert.strictEqual(catReleaseLabel(undefined), '');
|
||||
assert.strictEqual(catReleaseLabel(4), 'pre-Rel-6');
|
||||
assert.strictEqual(catReleaseLabel(5), 'pre-Rel-6');
|
||||
assert.strictEqual(catReleaseLabel(6), 'Rel-6');
|
||||
assert.strictEqual(catReleaseLabel(18), 'Rel-18');
|
||||
});
|
||||
|
||||
test('presetRelease resolves unspecified presets to the default', () => {
|
||||
assert.strictEqual(CAT_RELEASE_DEFAULT, 12);
|
||||
assert.strictEqual(presetRelease({ release: '14' }), 14);
|
||||
assert.strictEqual(presetRelease({ release: 4 }), 4);
|
||||
assert.strictEqual(presetRelease({ release: '18' }), 18);
|
||||
// an unset or invalid release must never filter anything away
|
||||
for (const p of [{}, { release: '' }, { release: '0' }, { release: '3' },
|
||||
{ release: '99' }, { release: 'x' }, null, undefined]) {
|
||||
assert.strictEqual(presetRelease(p), CAT_RELEASE_DEFAULT, JSON.stringify(p));
|
||||
}
|
||||
});
|
||||
|
||||
test('catEffectiveReleaseInfo resolves the equipped preset or the default', () => {
|
||||
const list = [{ name: 'A', iccid: '89 70 1190 0000 4600 098', release: '17' },
|
||||
{ name: 'B', iccid: '', release: '14' }];
|
||||
const hit = catEffectiveReleaseInfo(list, '8970119000004600098');
|
||||
assert.strictEqual(hit.rel, 17);
|
||||
assert.strictEqual(hit.source, 'preset');
|
||||
assert.strictEqual(hit.preset.name, 'A');
|
||||
// an ICCID that matches no preset (and the empty one) resolves to the default
|
||||
const miss = catEffectiveReleaseInfo(list, '8970119000004600099');
|
||||
assert.strictEqual(miss.rel, CAT_RELEASE_DEFAULT);
|
||||
assert.strictEqual(miss.source, 'default');
|
||||
assert.strictEqual(miss.preset, null);
|
||||
assert.strictEqual(catEffectiveReleaseInfo(list, '').source, 'default');
|
||||
assert.strictEqual(catEffectiveReleaseInfo([], '8970119000004600098').rel, CAT_RELEASE_DEFAULT);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\nvar _pysimCustomDropped = 0;\nvar pysimFsTreeRoot = null;\n';
|
||||
for (const fn of ['pysimCustomNormPath', 'pysimCustomKindForName', 'pysimCustomFid',
|
||||
'pysimCustomParent', 'pysimCustomRoot', 'pysimCustomKnownDfPaths', 'pysimCustomParentStatus',
|
||||
'pysimCustomValidate', 'pysimCustomRewriteDescendants', 'pysimCustomNormalizeEntries',
|
||||
'pysimCustomSave', 'pysimCustomRenderRoots', 'pysimCustomRenderParents',
|
||||
'pysimCustomRootChanged', 'pysimCustomParentInput', 'pysimCustomSubmit',
|
||||
'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender',
|
||||
'pysimFsNodePath', 'pysimFsFindNodeByPath', 'pysimCustomInject', 'pysimCustomRefreshTree']) {
|
||||
// pysimCustomRemove is async (it awaits the shared confirm dialog)
|
||||
code += extractFunc(html, fn, fn === 'pysimCustomRemove') + '\n';
|
||||
}
|
||||
code += html.match(/const CUSTOM_ROOTS = \[[^\]]*\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimFsRenderTree = () => {};\n';
|
||||
eval(code);
|
||||
// The dialog sweep replaced the native alert/confirm with the helpers: route
|
||||
// them to the native stubs setup() installs on globalThis.
|
||||
globalThis.pysimAlert = m => globalThis.alert(m);
|
||||
globalThis.pysimConfirm = async m => globalThis.confirm(m);
|
||||
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
|
||||
|
||||
function fakeEl(id) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, value: '', innerHTML: '', textContent: '', attrs: {}, focused: 0,
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
focus() { this.focused++; },
|
||||
options() { return [...this.innerHTML.matchAll(/value="([^"]+)"/g)].map(m => m[1]); },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(entries) {
|
||||
const els = {
|
||||
'pysim-cf-root': fakeEl('pysim-cf-root'),
|
||||
'pysim-cf-parent': fakeEl('pysim-cf-parent'),
|
||||
'pysim-cf-parent-list': fakeEl('pysim-cf-parent-list'),
|
||||
'pysim-cf-fid': fakeEl('pysim-cf-fid'),
|
||||
'pysim-cf-name': fakeEl('pysim-cf-name'),
|
||||
'pysim-cf-list': fakeEl('pysim-cf-list'),
|
||||
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
|
||||
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
|
||||
};
|
||||
els['pysim-cf-cancel-btn'].classList.add('hidden');
|
||||
const store = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.alertCalls = [];
|
||||
globalThis.alert = m => { globalThis.alertCalls.push(m); };
|
||||
globalThis.confirmResult = true;
|
||||
globalThis.confirm = () => globalThis.confirmResult;
|
||||
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
|
||||
pysimCustomEditIndex = null;
|
||||
_pysimCustomDropped = 0;
|
||||
pysimFsTreeRoot = null;
|
||||
pysimCustomRenderRoots();
|
||||
pysimCustomRenderParents();
|
||||
return els;
|
||||
}
|
||||
|
||||
function fill(els, root, parent, fid, name) {
|
||||
els['pysim-cf-root'].value = root;
|
||||
pysimCustomRenderParents();
|
||||
els['pysim-cf-parent'].value = parent;
|
||||
els['pysim-cf-fid'].value = fid;
|
||||
els['pysim-cf-name'].value = name;
|
||||
}
|
||||
|
||||
test('helpers derive canonical roots, parents, FIDs and kinds', () => {
|
||||
assert.strictEqual(pysimCustomNormPath('3f00/7f20/6f46'), 'MF/7F20/6F46');
|
||||
assert.strictEqual(pysimCustomNormPath(' mf / a153 '), 'MF/A153');
|
||||
assert.strictEqual(pysimCustomNormPath(''), '');
|
||||
assert.strictEqual(pysimCustomRoot({ path: 'ADF.USIM/6F07' }), 'ADF.USIM');
|
||||
assert.strictEqual(pysimCustomParent({ path: 'MF/A153/4954' }), 'MF/A153');
|
||||
assert.strictEqual(pysimCustomParent({ path: 'MF/6F46' }), 'MF');
|
||||
assert.strictEqual(pysimCustomFid({ path: 'MF/A153/4954' }), '4954');
|
||||
assert.strictEqual(pysimCustomKindForName('EF.SPN'), 'ef');
|
||||
assert.strictEqual(pysimCustomKindForName('DF.GSM'), 'df');
|
||||
assert.strictEqual(pysimCustomKindForName('XX.SPN'), null);
|
||||
});
|
||||
|
||||
test('migration resolves legacy relative paths and drops the unresolvable', () => {
|
||||
const res = pysimCustomNormalizeEntries([
|
||||
{ path: '3f00/a153', name: 'DF.A1' },
|
||||
{ path: 'a153/4954', name: 'EF.SPNS', fid: '4954', parentFid: 'a153' },
|
||||
{ path: 'a153/4955', name: 'EF.SMSCS' },
|
||||
{ path: 'ffff/1111', name: 'EF.ORPHAN' }, // no such custom DF -> dropped
|
||||
{ path: 'MF/6F46', name: 'BAD.NAME' }, // invalid alias -> dropped
|
||||
], []);
|
||||
assert.deepStrictEqual(res.files, [
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(res.dropped, 2);
|
||||
});
|
||||
|
||||
test('validation accepts standard/unknown parents but rejects known EFs', () => {
|
||||
const files = [{ path: 'MF/A153', name: 'DF.A1', kind: 'df' }];
|
||||
// a parent that is not a custom entry is accepted: it may be a standard DF
|
||||
// from the card model, or simply not seen in the tree yet
|
||||
const unknown = pysimCustomValidate('MF', 'MF/A999', '6F46', 'EF.SPN', files, null);
|
||||
assert.strictEqual(unknown.error, null);
|
||||
assert.strictEqual(unknown.path, 'MF/A999/6F46');
|
||||
// parent defined -> ok
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
|
||||
// a bare FID chain typed without the root is completed from the selector
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'A153', '6F46', 'EF.SPN', files, null).path, 'MF/A153/6F46');
|
||||
// deep chains are fine
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/7F20/5F01', '6F46', 'EF.DEEP', [], null).path, 'MF/7F20/5F01/6F46');
|
||||
// a parent known here to be an EF is rejected
|
||||
assert.match(pysimCustomValidate('MF', 'MF/A153/6F46', '1234', 'EF.X',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /not a DF/);
|
||||
// parent segments must be 4-hex FIDs
|
||||
assert.match(pysimCustomValidate('MF', 'MF/FOO', '6F46', 'EF.SPN', files, null).error, /4-hex/);
|
||||
// bad FID
|
||||
assert.match(pysimCustomValidate('MF', 'MF', '6F4', 'EF.SPN', files, null).error, /4 hex/);
|
||||
// bad alias
|
||||
assert.match(pysimCustomValidate('MF', 'MF', '6F46', 'SPN', files, null).error, /EF\.|DF\./);
|
||||
// duplicate
|
||||
assert.match(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), null).error, /already defined/);
|
||||
// editing the same entry is not a duplicate
|
||||
assert.strictEqual(pysimCustomValidate('MF', 'MF/A153', '6F46', 'EF.SPN',
|
||||
files.concat([{ path: 'MF/A153/6F46', name: 'EF.OTHER', kind: 'ef' }]), 1).error, null);
|
||||
// root must be known
|
||||
assert.match(pysimCustomValidate('MFX', 'MFX', '6F46', 'EF.SPN', files, null).error, /Root/);
|
||||
});
|
||||
|
||||
test('root and parent suggestions list roots, custom DFs and tree DFs', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'ADF.USIM/6F07', name: 'EF.IMSI', kind: 'ef' },
|
||||
]);
|
||||
assert.deepStrictEqual(els['pysim-cf-root'].options(), ['MF', 'ADF.USIM', 'ADF.ISIM']);
|
||||
let parents = els['pysim-cf-parent-list'].options();
|
||||
assert.ok(parents.includes('MF'));
|
||||
assert.ok(parents.includes('MF/A153'));
|
||||
assert.ok(!parents.includes('MF/A153/4954'), 'EFs are not parent options');
|
||||
// DFs known from the loaded file tree are suggested, at any depth
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
|
||||
const df = { name: 'DF.TELECOM', fid: '7F10', isDir: true, parent: pysimFsTreeRoot, children: [] };
|
||||
const sub = { name: 'DF.SUB', fid: '5F01', isDir: true, parent: df, children: [] };
|
||||
df.children = [sub];
|
||||
pysimFsTreeRoot.children = [df];
|
||||
pysimCustomRenderParents();
|
||||
parents = els['pysim-cf-parent-list'].options();
|
||||
assert.ok(parents.includes('MF/7F10'));
|
||||
assert.ok(parents.includes('MF/7F10/5F01'));
|
||||
// the ADF root is always a valid parent
|
||||
els['pysim-cf-root'].value = 'ADF.USIM';
|
||||
pysimCustomRootChanged();
|
||||
assert.deepStrictEqual(els['pysim-cf-parent-list'].options(), ['ADF.USIM']);
|
||||
});
|
||||
|
||||
test('parent status classifies root, custom, tree and unknown parents', () => {
|
||||
setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(pysimCustomParentStatus('MF').status, 'root');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/A153').status, 'df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/A153/4954').status, 'not-df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/FFFF').status, 'unknown');
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
|
||||
const df = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: pysimFsTreeRoot, children: [] };
|
||||
df.children = [{ name: 'EF.SPN', fid: '6F46', isDir: false, parent: df, children: null }];
|
||||
pysimFsTreeRoot.children = [df];
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20').status, 'df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20/6F46').status, 'not-df');
|
||||
assert.strictEqual(pysimCustomParentStatus('MF/7F20/9999').status, 'unknown');
|
||||
});
|
||||
|
||||
test('import keeps canonical entries whose parent is outside the custom list', () => {
|
||||
const res = pysimCustomNormalizeEntries([
|
||||
{ path: '3F00/7f20/5f01/6f46', name: 'EF.DEEP' },
|
||||
{ path: 'mf/ffff/6f46', name: 'EF.ORPHAN' },
|
||||
{ path: 'MF/6F46', name: 'NOPE' },
|
||||
], []);
|
||||
assert.deepStrictEqual(res.files, [
|
||||
{ path: 'MF/7F20/5F01/6F46', name: 'EF.DEEP', kind: 'ef' },
|
||||
{ path: 'MF/FFFF/6F46', name: 'EF.ORPHAN', kind: 'ef' },
|
||||
]);
|
||||
assert.strictEqual(res.dropped, 1);
|
||||
});
|
||||
|
||||
test('submit adds files under the root and under a defined DF', () => {
|
||||
const els = setup([]);
|
||||
fill(els, 'MF', 'MF', '6F46', 'EF.SPN');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles, [{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' }]);
|
||||
assert.strictEqual(els['pysim-cf-fid'].value, '');
|
||||
// add a DF, then a file under it
|
||||
fill(els, 'MF', 'MF', 'A153', 'DF.A1');
|
||||
pysimCustomSubmit();
|
||||
fill(els, 'MF', 'MF/A153', '4954', 'EF.SPNS');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/6F46', 'MF/A153', 'MF/A153/4954']);
|
||||
// a parent that is not a custom entry is allowed (it may be a standard DF
|
||||
// the tree has not loaded yet)
|
||||
fill(els, 'MF', 'MF/A153', '2222', 'EF.ORPHAN');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/6F46', 'MF/A153', 'MF/A153/4954', 'MF/A153/2222']);
|
||||
assert.strictEqual(globalThis.alertCalls.length, 0);
|
||||
// a parent known here to be an EF is rejected
|
||||
fill(els, 'MF', 'MF/A153/4954', '3333', 'EF.NOPE');
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['Parent is not a DF: MF/A153/4954']);
|
||||
});
|
||||
|
||||
test('editing a DF FID rewrites its descendants', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/A153/4955', name: 'EF.SMSCS', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomEdit(0);
|
||||
assert.strictEqual(els['pysim-cf-fid'].value, 'A153');
|
||||
els['pysim-cf-fid'].value = 'A154';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path),
|
||||
['MF/A154', 'MF/A154/4954', 'MF/A154/4955']);
|
||||
assert.strictEqual(pysimCustomEditIndex, null);
|
||||
});
|
||||
|
||||
test('editing an entry to another defined path is rejected', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
|
||||
{ path: 'MF/6F44', name: 'EF.SPN2', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomEdit(0);
|
||||
els['pysim-cf-fid'].value = '6F44';
|
||||
pysimCustomSubmit();
|
||||
assert.deepStrictEqual(globalThis.alertCalls, ['File already defined: MF/6F44']);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46', 'MF/6F44']);
|
||||
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||
});
|
||||
|
||||
test('deleting a DF cascades to its children only after confirmation', async () => {
|
||||
setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
{ path: 'MF/6F46', name: 'EF.SPN', kind: 'ef' },
|
||||
]);
|
||||
globalThis.confirmResult = false;
|
||||
await pysimCustomRemove(0);
|
||||
assert.strictEqual(pysimCustomFiles.length, 3, 'cancelled delete must keep everything');
|
||||
globalThis.confirmResult = true;
|
||||
await pysimCustomRemove(0);
|
||||
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['MF/6F46']);
|
||||
});
|
||||
|
||||
test('render shows the canonical path, kind and row actions', () => {
|
||||
const els = setup([
|
||||
{ path: 'MF/A153', name: 'DF.A1', kind: 'df' },
|
||||
{ path: 'MF/A153/4954', name: 'EF.SPNS', kind: 'ef' },
|
||||
]);
|
||||
pysimCustomRender();
|
||||
const out = els['pysim-cf-list'].innerHTML;
|
||||
assert.match(out, /MF\/A153/);
|
||||
assert.match(out, /MF\/A153\/4954/);
|
||||
assert.match(out, /\(DF\)/);
|
||||
assert.match(out, /pysimCustomEdit\(0\)/);
|
||||
assert.match(out, /pysimCustomRemove\(1\)/);
|
||||
});
|
||||
|
||||
test('render flags dropped legacy entries', () => {
|
||||
const els = setup([]);
|
||||
_pysimCustomDropped = 3;
|
||||
pysimCustomRender();
|
||||
assert.match(els['pysim-cf-list'].innerHTML, /3/);
|
||||
_pysimCustomDropped = 0;
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimCustomFiles = [];\nvar pysimFsTreeRoot = null;\n';
|
||||
for (const fn of ['pysimFsNodePath', 'pysimCustomInject', 'pysimCustomRefreshTree', 'pysimCustomParent', 'pysimCustomFid']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.pysimFsRenderTree = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
function node(name, fid, parent, children) {
|
||||
return { name, fid, parent: parent || null, children: children || null, isDir: true };
|
||||
}
|
||||
|
||||
function tree() {
|
||||
const mf = node('MF', '3F00', null, []);
|
||||
const dfA = node('DF.A', '5F01', mf);
|
||||
const dfB = node('DF.B', '5F02', mf);
|
||||
const dfC = node('DF.C', '5F03', mf, [node('EF.X', '6F46', null)]);
|
||||
dfC.children[0].parent = dfC;
|
||||
const adf = node('ADF.USIM', '7FFF', mf);
|
||||
const efImsi = node('EF.IMSI', '6F07', adf);
|
||||
adf.children = [efImsi];
|
||||
mf.children = [dfA, dfB, dfC, adf];
|
||||
return { mf, dfA, dfB, dfC, adf, efImsi };
|
||||
}
|
||||
|
||||
test('pysimFsNodePath builds canonical paths from MF and ADF roots', () => {
|
||||
const t = tree();
|
||||
assert.strictEqual(pysimFsNodePath(t.mf), 'MF');
|
||||
assert.strictEqual(pysimFsNodePath(t.dfA), 'MF/5F01');
|
||||
assert.strictEqual(pysimFsNodePath(t.dfC.children[0]), 'MF/5F03/6F46');
|
||||
assert.strictEqual(pysimFsNodePath(t.adf), 'ADF.USIM');
|
||||
assert.strictEqual(pysimFsNodePath(t.efImsi), 'ADF.USIM/6F07');
|
||||
});
|
||||
|
||||
test('injection matches full paths, so same-FID DFs stay apart', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [
|
||||
{ path: 'MF/5F01/6F46', name: 'EF.ONLY-A', kind: 'ef' },
|
||||
{ path: 'MF/5F02/6F46', name: 'EF.ONLY-B', kind: 'ef' },
|
||||
{ path: 'ADF.USIM/6F07', name: 'EF.MY-IMSI', kind: 'ef' },
|
||||
];
|
||||
pysimCustomInject(t.mf);
|
||||
assert.strictEqual(t.mf.children.length, 4, 'MF-level injection adds nothing');
|
||||
pysimCustomInject(t.dfA);
|
||||
assert.strictEqual(t.dfA.children.length, 1);
|
||||
assert.strictEqual(t.dfA.children[0].name, 'EF.ONLY-A');
|
||||
assert.strictEqual(t.dfA.children[0].customPath, 'MF/5F01/6F46');
|
||||
pysimCustomInject(t.dfB);
|
||||
assert.strictEqual(t.dfB.children[0].name, 'EF.ONLY-B');
|
||||
pysimCustomInject(t.dfC);
|
||||
assert.strictEqual(t.dfC.children.length, 1, 'unrelated DF is untouched');
|
||||
pysimCustomInject(t.adf);
|
||||
assert.strictEqual(t.adf.children.length, 1);
|
||||
assert.strictEqual(t.adf.children[0].name, 'EF.MY-IMSI');
|
||||
assert.strictEqual(t.adf.children[0].customPath, 'ADF.USIM/6F07');
|
||||
});
|
||||
|
||||
test('injection renames and marks an existing model node', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
|
||||
pysimCustomInject(t.dfC);
|
||||
assert.strictEqual(t.dfC.children.length, 1);
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
|
||||
assert.strictEqual(t.dfC.children[0].custom, true);
|
||||
});
|
||||
|
||||
test('injected DFs are directories and can host their own children', () => {
|
||||
const t = tree();
|
||||
pysimCustomFiles = [
|
||||
{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' },
|
||||
{ path: 'MF/5F10/6F46', name: 'EF.UNDER-NEW', kind: 'ef' },
|
||||
];
|
||||
pysimCustomInject(t.mf);
|
||||
const df = t.mf.children.find(c => c.fid === '5F10');
|
||||
assert.ok(df, 'custom DF injected into MF');
|
||||
assert.strictEqual(df.isDir, true);
|
||||
assert.strictEqual(pysimFsNodePath(df), 'MF/5F10');
|
||||
pysimCustomInject(df);
|
||||
assert.strictEqual(df.children.length, 1);
|
||||
assert.strictEqual(df.children[0].name, 'EF.UNDER-NEW');
|
||||
});
|
||||
|
||||
test('refresh restores renamed model nodes and drops injected ones', () => {
|
||||
const t = tree();
|
||||
pysimFsTreeRoot = t.mf;
|
||||
pysimCustomFiles = [{ path: 'MF/5F03/6F46', name: 'EF.RENAMED', kind: 'ef' }];
|
||||
pysimCustomRefreshTree();
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.RENAMED');
|
||||
assert.strictEqual(t.dfC.children[0].custom, true);
|
||||
// deleting the entry brings the model name back
|
||||
pysimCustomFiles = [];
|
||||
pysimCustomRefreshTree();
|
||||
assert.strictEqual(t.dfC.children[0].name, 'EF.X');
|
||||
assert.ok(!t.dfC.children[0].custom);
|
||||
assert.strictEqual(t.dfC.children[0].modelName, undefined);
|
||||
// injected nodes disappear together with their entry
|
||||
pysimCustomFiles = [{ path: 'MF/5F10', name: 'DF.NEW', kind: 'df' }];
|
||||
pysimCustomRefreshTree();
|
||||
assert.ok(t.mf.children.some(c => c.fid === '5F10'));
|
||||
pysimCustomFiles = [];
|
||||
pysimCustomRefreshTree();
|
||||
assert.ok(!t.mf.children.some(c => c.fid === '5F10'));
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
// In-app dialog tests: the shared alert/confirm/prompt modal, its promise
|
||||
// queue and the native fallback. Mirrors the ram_batch harness (extractFunc
|
||||
// from index.html, a sandbox built with new Function, an els map and a
|
||||
// document stub).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['pysimAlert', 'pysimConfirm', 'pysimPrompt', '_pysimDialogShow',
|
||||
'_pysimDialogNow', '_pysimDialogRun', 'pysimDialogAnswer', '_pysimDialogKeydown']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
|
||||
const els = {};
|
||||
function setEl(id, value) {
|
||||
els[id] = {
|
||||
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
||||
disabled: false, style: {}, focused: false, selected: false,
|
||||
classes: {},
|
||||
classList: {
|
||||
add(c) { els[id].classes[c] = true; },
|
||||
remove(c) { delete els[id].classes[c]; },
|
||||
toggle(c, on) { if (on) els[id].classes[c] = true; else delete els[id].classes[c]; },
|
||||
contains(c) { return !!els[id].classes[c]; },
|
||||
},
|
||||
focus() { els[id].focused = true; },
|
||||
select() { els[id].selected = true; },
|
||||
};
|
||||
return els[id];
|
||||
}
|
||||
function clearEls() {
|
||||
for (const k of Object.keys(els)) delete els[k];
|
||||
}
|
||||
// The five elements the dialog touches.
|
||||
function setDialogEls() {
|
||||
for (const id of ['pysim-dialog-modal', 'pysim-dialog-message', 'pysim-dialog-input',
|
||||
'pysim-dialog-cancel', 'pysim-dialog-ok']) setEl(id, '');
|
||||
}
|
||||
|
||||
// The document stub records the keydown listener, so the tests exercise the
|
||||
// open/close wiring instead of calling the handler directly.
|
||||
const listeners = {};
|
||||
const documentStub = {
|
||||
getElementById: id => els[id] || null,
|
||||
activeElement: null,
|
||||
addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn); },
|
||||
removeEventListener: (type, fn) => {
|
||||
const a = listeners[type] || [];
|
||||
const i = a.indexOf(fn);
|
||||
if (i >= 0) a.splice(i, 1);
|
||||
},
|
||||
};
|
||||
function pressKey(key, extra) {
|
||||
const ev = Object.assign({ key: key, preventDefault() {} }, extra || {});
|
||||
for (const fn of (listeners['keydown'] || []).slice()) fn(ev);
|
||||
}
|
||||
const tick = () => new Promise(r => setImmediate(r));
|
||||
|
||||
const app = new Function('els', 'document', `
|
||||
var sandbox = { alerts: [], confirms: [], prompts: [], confirmAnswer: true, promptAnswer: '' };
|
||||
var alert = m => sandbox.alerts.push(String(m));
|
||||
var confirm = m => { sandbox.confirms.push(String(m)); return sandbox.confirmAnswer; };
|
||||
var prompt = (m, v) => { sandbox.prompts.push([String(m), v]); return sandbox.promptAnswer; };
|
||||
var _pysimDialogQueue = Promise.resolve();
|
||||
var _pysimDialogResolve = null;
|
||||
var _pysimDialogKind = null;
|
||||
var _pysimDialogOpenedAt = 0;
|
||||
${code}
|
||||
sandbox.pysimAlert = pysimAlert;
|
||||
sandbox.pysimConfirm = pysimConfirm;
|
||||
sandbox.pysimPrompt = pysimPrompt;
|
||||
sandbox.pysimDialogAnswer = pysimDialogAnswer;
|
||||
sandbox.getOpenedAt = () => _pysimDialogOpenedAt;
|
||||
return sandbox;
|
||||
`)(els, documentStub);
|
||||
|
||||
test('pysimAlert sets the message, hides Cancel and resolves on OK', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
const p = app.pysimAlert('Hello');
|
||||
await tick();
|
||||
assert.strictEqual(els['pysim-dialog-message'].textContent, 'Hello');
|
||||
assert.ok(els['pysim-dialog-modal'].classes['hidden'] === undefined);
|
||||
assert.ok(els['pysim-dialog-cancel'].classes['hidden']); // an alert has no Cancel
|
||||
assert.ok(els['pysim-dialog-input'].classes['hidden']); // and no input
|
||||
assert.strictEqual((listeners.keydown || []).length, 1); // the keydown listener is armed
|
||||
app.pysimDialogAnswer(true);
|
||||
assert.ok(els['pysim-dialog-modal'].classes['hidden']);
|
||||
assert.strictEqual(await p, undefined);
|
||||
});
|
||||
|
||||
test('pysimConfirm resolves true on OK and false on Cancel/Escape', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
let p = app.pysimConfirm('Sure?');
|
||||
await tick();
|
||||
assert.ok(els['pysim-dialog-cancel'].classes['hidden'] === undefined);
|
||||
app.pysimDialogAnswer(true);
|
||||
assert.strictEqual(await p, true);
|
||||
// the Cancel button
|
||||
p = app.pysimConfirm('Sure?');
|
||||
await tick();
|
||||
app.pysimDialogAnswer(false);
|
||||
assert.strictEqual(await p, false);
|
||||
// Escape cancels
|
||||
p = app.pysimConfirm('Sure?');
|
||||
await tick();
|
||||
pressKey('Escape');
|
||||
assert.strictEqual(await p, false);
|
||||
// Enter accepts
|
||||
p = app.pysimConfirm('Sure?');
|
||||
await tick();
|
||||
pressKey('Enter');
|
||||
assert.strictEqual(await p, true);
|
||||
});
|
||||
|
||||
test('pysimPrompt prefills the default, returns the typed value or null', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
let p = app.pysimPrompt('Name?', 'default');
|
||||
await tick();
|
||||
assert.ok(els['pysim-dialog-input'].classes['hidden'] === undefined);
|
||||
assert.strictEqual(els['pysim-dialog-input'].value, 'default');
|
||||
assert.ok(els['pysim-dialog-input'].focused); // the input is focused
|
||||
assert.ok(els['pysim-dialog-input'].selected); // with its text selected
|
||||
els['pysim-dialog-input'].value = 'typed';
|
||||
app.pysimDialogAnswer(true);
|
||||
assert.strictEqual(await p, 'typed');
|
||||
// Cancel discards the typed value
|
||||
p = app.pysimPrompt('Name?', 'default');
|
||||
await tick();
|
||||
els['pysim-dialog-input'].value = 'typed';
|
||||
app.pysimDialogAnswer(false);
|
||||
assert.strictEqual(await p, null);
|
||||
// Escape is a cancel too
|
||||
p = app.pysimPrompt('Name?', 'x');
|
||||
await tick();
|
||||
pressKey('Escape');
|
||||
assert.strictEqual(await p, null);
|
||||
});
|
||||
|
||||
test('overlapping calls queue in order (one dialog at a time)', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
let firstDone = false;
|
||||
let secondDone = false;
|
||||
const p1 = app.pysimAlert('first');
|
||||
p1.then(() => { firstDone = true; });
|
||||
const p2 = app.pysimConfirm('second');
|
||||
p2.then(() => { secondDone = true; });
|
||||
await tick();
|
||||
// only the first is open; the second waits
|
||||
assert.strictEqual(els['pysim-dialog-message'].textContent, 'first');
|
||||
assert.ok(els['pysim-dialog-cancel'].classes['hidden']);
|
||||
assert.ok(!firstDone && !secondDone);
|
||||
app.pysimDialogAnswer(true);
|
||||
await tick();
|
||||
// the second opens now, with its own message and Cancel
|
||||
assert.strictEqual(els['pysim-dialog-message'].textContent, 'second');
|
||||
assert.ok(els['pysim-dialog-cancel'].classes['hidden'] === undefined);
|
||||
assert.ok(firstDone && !secondDone);
|
||||
app.pysimDialogAnswer(false);
|
||||
assert.strictEqual(await p2, false);
|
||||
});
|
||||
|
||||
test('a second answer after the dialog resolved is a no-op', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
const p = app.pysimConfirm('once');
|
||||
await tick();
|
||||
app.pysimDialogAnswer(true);
|
||||
app.pysimDialogAnswer(false); // must not change the settled result
|
||||
assert.strictEqual(await p, true);
|
||||
// the keydown listener is removed when the dialog closes
|
||||
assert.strictEqual((listeners.keydown || []).length, 0);
|
||||
});
|
||||
|
||||
test('without the modal the native dialogs are used', async () => {
|
||||
clearEls(); // no #pysim-dialog-modal in the els map
|
||||
listeners.keydown = [];
|
||||
app.alerts = [];
|
||||
app.confirms = [];
|
||||
app.prompts = [];
|
||||
app.confirmAnswer = true;
|
||||
app.promptAnswer = 'native';
|
||||
assert.strictEqual(await app.pysimAlert('A'), undefined);
|
||||
assert.deepStrictEqual(app.alerts, ['A']);
|
||||
assert.strictEqual(await app.pysimConfirm('C'), true);
|
||||
assert.deepStrictEqual(app.confirms, ['C']);
|
||||
app.confirmAnswer = false;
|
||||
assert.strictEqual(await app.pysimConfirm('C2'), false);
|
||||
assert.strictEqual(await app.pysimPrompt('P', 'def'), 'native');
|
||||
assert.deepStrictEqual(app.prompts, [['P', 'def']]);
|
||||
assert.strictEqual((listeners.keydown || []).length, 0);
|
||||
});
|
||||
|
||||
test('Enter with the Cancel button focused cancels', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = els['pysim-dialog-cancel'];
|
||||
const p = app.pysimConfirm('Cancel focus?');
|
||||
await tick();
|
||||
pressKey('Enter');
|
||||
assert.strictEqual(await p, false);
|
||||
documentStub.activeElement = null;
|
||||
});
|
||||
|
||||
test('a repeated Enter does not answer a queued dialog', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = null;
|
||||
const p1 = app.pysimConfirm('first');
|
||||
const p2 = app.pysimConfirm('second');
|
||||
await tick();
|
||||
pressKey('Enter', { repeat: true }); // a held key: ignored
|
||||
pressKey('Enter', { repeat: true });
|
||||
assert.strictEqual(els['pysim-dialog-message'].textContent, 'first');
|
||||
assert.ok(els['pysim-dialog-modal'].classes['hidden'] === undefined);
|
||||
pressKey('Enter'); // a real press answers it
|
||||
await tick();
|
||||
assert.strictEqual(await p1, true);
|
||||
assert.strictEqual(els['pysim-dialog-message'].textContent, 'second');
|
||||
app.pysimDialogAnswer(true);
|
||||
assert.strictEqual(await p2, true);
|
||||
});
|
||||
|
||||
test('an event answer inside the grace window is ignored, a later one is accepted', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = null;
|
||||
const p = app.pysimConfirm('grace');
|
||||
await tick();
|
||||
// a pointer answer within the grace window: ignored (a double-click's 2nd
|
||||
// click on the freshly opened dialog)
|
||||
app.pysimDialogAnswer(true, { type: 'click', timeStamp: app.getOpenedAt() + 50 });
|
||||
assert.ok(els['pysim-dialog-modal'].classes['hidden'] === undefined);
|
||||
// a programmatic answer without an event is never ignored
|
||||
app.pysimDialogAnswer(true);
|
||||
assert.strictEqual(await p, true);
|
||||
// a keyboard answer is never grace-checked (a deliberate Enter must work)
|
||||
const p2 = app.pysimConfirm('keyboard');
|
||||
await tick();
|
||||
app.pysimDialogAnswer(false, { type: 'keydown', timeStamp: app.getOpenedAt() + 5 });
|
||||
assert.strictEqual(await p2, false);
|
||||
// a pointer answer outside the window: accepted
|
||||
const p3 = app.pysimConfirm('later');
|
||||
await tick();
|
||||
app.pysimDialogAnswer(false, { type: 'click', timeStamp: app.getOpenedAt() + 600 });
|
||||
assert.strictEqual(await p3, false);
|
||||
});
|
||||
|
||||
// The dialog's own controls, in tab order; the trap must cycle them.
|
||||
const FOCUS_IDS = ['pysim-dialog-cancel', 'pysim-dialog-input', 'pysim-dialog-ok'];
|
||||
function resetFocus() { for (const id of FOCUS_IDS) if (els[id]) els[id].focused = false; }
|
||||
function focusedIds() { return FOCUS_IDS.filter(id => els[id] && els[id].focused); }
|
||||
function tab(extra) {
|
||||
resetFocus();
|
||||
let prevented = false;
|
||||
pressKey('Tab', Object.assign({ preventDefault: () => { prevented = true; } }, extra || {}));
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test('Tab cycles the dialog controls and cannot leave the modal', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = null;
|
||||
const p = app.pysimConfirm('trap');
|
||||
await tick();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-ok']); // the default focus
|
||||
// the browser's own focus move is suppressed
|
||||
assert.ok(tab(), 'Tab must be handled, not left to the browser');
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-cancel']); // wraps to the first
|
||||
documentStub.activeElement = els['pysim-dialog-cancel'];
|
||||
assert.ok(tab());
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-ok']);
|
||||
// Shift+Tab walks back
|
||||
documentStub.activeElement = els['pysim-dialog-ok'];
|
||||
assert.ok(tab({ shiftKey: true }));
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-cancel']);
|
||||
// a focus that escaped the dialog is pulled back in
|
||||
documentStub.activeElement = null;
|
||||
assert.ok(tab());
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-cancel']);
|
||||
documentStub.activeElement = null;
|
||||
assert.ok(tab({ shiftKey: true }));
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-ok']);
|
||||
app.pysimDialogAnswer(true);
|
||||
await p;
|
||||
});
|
||||
|
||||
test('the trap follows the dialog kind (alert: OK only, prompt: Cancel/input/OK)', async () => {
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = null;
|
||||
// an alert has a single control: Tab stays on it
|
||||
let p = app.pysimAlert('only ok');
|
||||
await tick();
|
||||
documentStub.activeElement = els['pysim-dialog-ok'];
|
||||
tab();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-ok']);
|
||||
app.pysimDialogAnswer(true);
|
||||
await p;
|
||||
// a prompt: Cancel -> input -> OK -> Cancel
|
||||
clearEls();
|
||||
setDialogEls();
|
||||
listeners.keydown = [];
|
||||
documentStub.activeElement = null;
|
||||
p = app.pysimPrompt('q', 'v');
|
||||
await tick();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-input']);
|
||||
documentStub.activeElement = els['pysim-dialog-input'];
|
||||
tab();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-ok']);
|
||||
documentStub.activeElement = els['pysim-dialog-ok'];
|
||||
tab();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-cancel']);
|
||||
documentStub.activeElement = els['pysim-dialog-cancel'];
|
||||
tab();
|
||||
assert.deepStrictEqual(focusedIds(), ['pysim-dialog-input']);
|
||||
app.pysimDialogAnswer(true);
|
||||
await p;
|
||||
});
|
||||
|
||||
test('a missing inner element falls back to the native dialog', async () => {
|
||||
clearEls();
|
||||
// the modal and every element but the OK button exist
|
||||
for (const id of ['pysim-dialog-modal', 'pysim-dialog-message', 'pysim-dialog-input',
|
||||
'pysim-dialog-cancel']) setEl(id, '');
|
||||
listeners.keydown = [];
|
||||
app.alerts = [];
|
||||
app.confirms = [];
|
||||
app.confirmAnswer = false;
|
||||
assert.strictEqual(await app.pysimConfirm('C'), false);
|
||||
assert.deepStrictEqual(app.confirms, ['C']);
|
||||
assert.strictEqual(await app.pysimAlert('A'), undefined);
|
||||
assert.deepStrictEqual(app.alerts, ['A']);
|
||||
assert.strictEqual((listeners.keydown || []).length, 0);
|
||||
});
|
||||
|
||||
test('the dialog markup and the helpers exist in index.html', () => {
|
||||
assert.match(html, /<div id="pysim-dialog-modal"[^>]*role="dialog"[^>]*aria-modal="true"[^>]*class="fixed inset-0 bg-black\/50 z-50 flex items-center justify-center hidden">/);
|
||||
assert.match(html, /id="pysim-dialog-message"/);
|
||||
assert.match(html, /id="pysim-dialog-input"[^>]*type="text"/);
|
||||
assert.match(html, /id="pysim-dialog-cancel"[^>]*onclick="pysimDialogAnswer\(false, event\)"[^>]*data-l10n="Cancel"/);
|
||||
assert.match(html, /id="pysim-dialog-ok"[^>]*onclick="pysimDialogAnswer\(true, event\)"[^>]*data-l10n="OK"/);
|
||||
for (const fn of ['pysimAlert', 'pysimConfirm', 'pysimPrompt', '_pysimDialogShow',
|
||||
'pysimDialogAnswer', '_pysimDialogKeydown']) {
|
||||
assert.ok(html.includes('function ' + fn), 'missing ' + fn);
|
||||
}
|
||||
});
|
||||
|
||||
test('every dialog class exists in the prebuilt stylesheet', () => {
|
||||
// The stale-style trap: style.css is a prebuilt Tailwind output, so a
|
||||
// class used only in index.html has no rule until `npm run build` runs.
|
||||
// Tailwind escapes ':', '/' and the bracket characters in the selectors;
|
||||
// stripping the backslashes mirrors the class names as written.
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8').replace(/\\/g, '');
|
||||
const start = html.indexOf('<div id="pysim-dialog-modal"');
|
||||
assert.ok(start > 0, 'the dialog markup is missing');
|
||||
const block = html.slice(start, html.indexOf('<script>', start));
|
||||
const classes = new Set();
|
||||
for (const m of block.matchAll(/class="([^"]+)"/g)) {
|
||||
for (const c of m[1].split(/\s+/)) if (c) classes.add(c);
|
||||
}
|
||||
assert.ok(classes.size >= 10, 'expected the dialog classes, got ' + classes.size);
|
||||
// Escape regex metacharacters and anchor the class end, so `.mx-4` cannot
|
||||
// be satisfied by a `.mx-40` rule (a substring check would accept it).
|
||||
const selRe = c => new RegExp('\\.' + c.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '(?![\\w-])');
|
||||
for (const c of classes) {
|
||||
assert.match(css, selRe(c), 'missing stylesheet rule for .' + c);
|
||||
}
|
||||
});
|
||||
|
||||
// A pure comment line is prose, not a call: "... with its prompt (v3.6.38)"
|
||||
// must not trip the space-tolerant scan below.
|
||||
function isCommentLine(line) {
|
||||
const t = line.trim();
|
||||
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
|
||||
}
|
||||
|
||||
test('no native dialog call site is left outside the fallback branch', () => {
|
||||
// The sweep moved every alert/confirm/prompt call site onto the helpers.
|
||||
// Only the intentional native fallback inside _pysimDialogRun and the PWA
|
||||
// install prompt (deferredPrompt.prompt()) may remain; a future bare call
|
||||
// names its line in the failure message.
|
||||
const lines = html.split('\n');
|
||||
const start = lines.findIndex(l => l.includes('function _pysimDialogRun('));
|
||||
assert.ok(start > 0, '_pysimDialogRun is missing');
|
||||
const end = lines.findIndex((l, i) => i > start && /^function /.test(l));
|
||||
assert.ok(end > start, 'the end of _pysimDialogRun is missing');
|
||||
const fallback = lines.slice(start, end).join('\n');
|
||||
assert.match(fallback, /\balert\(/, 'the native alert fallback is missing');
|
||||
assert.match(fallback, /\bconfirm\(/, 'the native confirm fallback is missing');
|
||||
assert.match(fallback, /\bprompt\(/, 'the native prompt fallback is missing');
|
||||
// catch a qualified native (window.alert / globalThis.confirm) and a
|
||||
// space before '(' too
|
||||
const re = /(?<![\w.])(?:(?:window|globalThis)\.)?(alert|confirm|prompt)\s*\(/;
|
||||
const violations = [];
|
||||
const missingAwait = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (re.test(line) && !(i >= start && i < end) &&
|
||||
!line.includes('deferredPrompt.prompt()') && !isCommentLine(line)) {
|
||||
violations.push((i + 1) + ': ' + line.trim());
|
||||
}
|
||||
// A missing await is the dangerous case: a Promise is always truthy,
|
||||
// so `if (pysimConfirm(x))` would silently never fire the guard.
|
||||
for (const fn of ['pysimConfirm', 'pysimPrompt']) {
|
||||
if (!new RegExp('\\b' + fn + '\\s*\\(').test(line)) continue;
|
||||
if (new RegExp('function\\s+' + fn + '\\s*\\(').test(line)) continue;
|
||||
if (new RegExp('await\\s+' + fn + '\\s*\\(').test(line)) continue;
|
||||
missingAwait.push((i + 1) + ': ' + line.trim());
|
||||
}
|
||||
});
|
||||
assert.deepStrictEqual(violations, [],
|
||||
'bare native dialog calls left outside _pysimDialogRun:\n' + violations.join('\n'));
|
||||
assert.deepStrictEqual(missingAwait, [],
|
||||
'pysimConfirm/pysimPrompt calls without await:\n' + missingAwait.join('\n'));
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const FNS = [
|
||||
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
|
||||
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText',
|
||||
'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact',
|
||||
'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
|
||||
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
|
||||
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn',
|
||||
'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo',
|
||||
'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes',
|
||||
'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten',
|
||||
'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml',
|
||||
'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects',
|
||||
'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath',
|
||||
'pysimCustomNormPath',
|
||||
];
|
||||
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\n';
|
||||
code += 'function esc(s){return String(s);}\nfunction escHtml(s){return String(s);}\nfunction t(k){return k;}\n';
|
||||
eval(code);
|
||||
|
||||
test('ef helpers: bytes, hex, swap, TLV, PLMN', () => {
|
||||
assert.strictEqual(efHex(efBytes('a0ff01')), 'A0FF01');
|
||||
assert.strictEqual(efSwapNibbles('1234'), '2143');
|
||||
assert.strictEqual(efPlmn(efBytes('22F860')).mcc, '228');
|
||||
assert.strictEqual(efPlmn(efBytes('22F860')).mnc, '06');
|
||||
assert.ok(efAllFf(efBytes('FFFF')));
|
||||
assert.ok(!efAllFf(efBytes('FF00')));
|
||||
// 05 03 AA BB CC + 1E 01 DD
|
||||
const tlvs = efTlv(efBytes('0503AABBCC1E01DD'));
|
||||
assert.deepStrictEqual(tlvs.map(t => t.tag), [0x05, 0x1E]);
|
||||
assert.strictEqual(efHex(tlvs[0].value), 'AABBCC');
|
||||
assert.strictEqual(efHex(tlvs[1].value), 'DD');
|
||||
// long-form length
|
||||
assert.strictEqual(efTlv(efBytes('058105' + '00'.repeat(5)))[0].value.length, 5);
|
||||
});
|
||||
|
||||
test('efAnnexA decodes GSM7 octets, UCS2 and packed variants', () => {
|
||||
assert.strictEqual(efAnnexA(efBytes('47534D2D52204348')), 'GSM-R CH'); // one octet per char
|
||||
assert.strictEqual(efAnnexA(efBytes('8000470053004D')), 'GSM'); // UCS2 prefix
|
||||
assert.strictEqual(efAnnexA(efBytes('')), '');
|
||||
assert.strictEqual(efAnnexA(efBytes('FFFFFFFF')), '');
|
||||
});
|
||||
|
||||
test('efPnnText uses the spare-bit count (TS 24.008 10.5.3.5a)', () => {
|
||||
// Live card EF.PNN records: coding octet + packed GSM-7 text. The spare
|
||||
// bits in the coding octet give the exact character count, so the zero
|
||||
// padding must not decode as a trailing '@' (record 2 was 'Miranda@').
|
||||
const cases = [
|
||||
['8441b6390c', 'Alfa'],
|
||||
['87cdb43cec268701', 'Miranda'],
|
||||
['83d7b41b', 'Win'],
|
||||
['83cdb41c442db3cbeb771b', 'Mir Telekom'],
|
||||
['82ab1b885a6697d7ef36', '+7 Telekom'],
|
||||
['85c6b23b8d07', 'Fenix'],
|
||||
['83cde514', 'MKS'],
|
||||
];
|
||||
for (const [hex, text] of cases) {
|
||||
assert.strictEqual(efPnnText(efBytes(hex)), text, hex);
|
||||
}
|
||||
// UCS2 coding scheme (octet 3 bits 5-7 = 001)
|
||||
assert.strictEqual(efPnnText(efBytes('91004d006900720061006e00640061')), 'Miranda');
|
||||
// a final <CR> used as padding is removed (TS 23.038 6.1.2.1.1)
|
||||
assert.strictEqual(gsm7Decode(efBytes('cdb43cec26871b'), 8), 'Miranda');
|
||||
});
|
||||
|
||||
test('EF decoders match the pinned spec / pySim test vectors', () => {
|
||||
assert.deepStrictEqual(efDecIccid(efBytes('988812010000400310f0')), { iccid: '8988211000000430010' });
|
||||
assert.deepStrictEqual(efDecImsi(efBytes('082982608200002080')), { imsi: '228062800000208' });
|
||||
assert.deepStrictEqual(efDecLi(efBytes('6465')), { languages: ['de'] });
|
||||
assert.deepStrictEqual(efDecLi(efBytes('FFFF')), { languages: [] });
|
||||
assert.deepStrictEqual(efDecSpn(efBytes('0147534d2d52204348ffffffffffffffff')), { show_in_hplmn: true, hide_in_oplmn: false, name: 'GSM-R CH' });
|
||||
assert.deepStrictEqual(efDecAcc(efBytes('0000')), { access_classes: [] });
|
||||
assert.deepStrictEqual(efDecAcc(efBytes('8000')), { access_classes: [0] });
|
||||
assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
|
||||
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
|
||||
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] });
|
||||
// EF.FPLMN: a FFFFFF gap in any position is skipped, not a terminator
|
||||
assert.deepStrictEqual(efDecFplmn(efBytes('22F860FFFFFF62F210')),
|
||||
{ plmns: [{ mcc: '228', mnc: '06' }, { mcc: '262', mnc: '01' }] });
|
||||
assert.deepStrictEqual(efDecFplmn(efBytes('FFFFFFFF')), { plmns: [] });
|
||||
assert.strictEqual(efFindDecoder('EF.EHPLMN', null).fn, efDecPlmnList);
|
||||
assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn);
|
||||
assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
|
||||
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
|
||||
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
|
||||
assert.deepStrictEqual(efDecNai(efBytes('8021696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267')),
|
||||
{ text: 'ims.mnc000.mcc738.3gppnetwork.org' });
|
||||
assert.deepStrictEqual(efDecImsi(efBytes('FFFF')), { empty: true });
|
||||
assert.deepStrictEqual(efDecIccid(efBytes('FFFFFFFFFFFF')), { empty: true });
|
||||
});
|
||||
|
||||
test('EF.SUME decodes the SET UP MENU title and icon TLVs (TS 51.011 10.3.34)', () => {
|
||||
// 05 05 "Menu!" + 1E 02 01 23 + FF padding
|
||||
const d = efDecSume(efBytes('05054D656E75211E020123FFFFFFFF'));
|
||||
assert.deepStrictEqual(d, { title: 'Menu!', icon: '0123' });
|
||||
assert.deepStrictEqual(efDecSume(efBytes('FFFFFFFF')), { empty: true });
|
||||
// UCS2 title: 05 09 80 00 4D 00 65 00 6E 00 75
|
||||
assert.deepStrictEqual(efDecSume(efBytes('050980004D0065006E0075FFFFFFFF')),
|
||||
{ title: 'Menu' });
|
||||
});
|
||||
|
||||
test('EF.PBR decodes the phonebook structure containers (TS 31.102 4.4.2.1)', () => {
|
||||
// pySim's EF_PBR fixture: A8 { C0 03 4F3A 02, C5 03 4F09 04 }
|
||||
// + AA { CB 03 4F3D 07, C2 03 4F4A 06 } - all with an SFI.
|
||||
const d = efDecPbr(efBytes('A80AC0034F3A02C5034F0904AA0ACB034F3D07C2034F4A06'));
|
||||
assert.strictEqual(d.master, '4F3A');
|
||||
assert.strictEqual(d.iap, null);
|
||||
assert.strictEqual(d.note, undefined);
|
||||
assert.deepStrictEqual(d.links, [
|
||||
{ link: 1, tag: 'C0', kind: 'EF.ADN', fid: '4F3A', sfi: '02' },
|
||||
{ link: 1, tag: 'C5', kind: 'EF.PBC', fid: '4F09', sfi: '04' },
|
||||
{ link: 3, tag: 'CB', kind: 'EF.CCP1', fid: '4F3D', sfi: '07' },
|
||||
{ link: 3, tag: 'C2', kind: 'EF.EXT1', fid: '4F4A', sfi: '06' },
|
||||
]);
|
||||
assert.deepStrictEqual(d.files, ['4F3A', '4F09', '4F3D', '4F4A']);
|
||||
});
|
||||
|
||||
test('EF.PBR: the no-SFI form and the type-2 (A9) container with EF_IAP', () => {
|
||||
// A8 { C0 02 4F3A (FID only, no SFI), C1 02 4F09 (EF_IAP) }
|
||||
// + A9 { C6 03 4F0B 06, C7 03 4F0C 07 } - the type-2 files are addressed
|
||||
// through the EF_IAP pointers, in the order of the FIDs after 'A9'.
|
||||
const d = efDecPbr(efBytes('A808C0024F3AC1024F09A90AC6034F0B06C7034F0C07'));
|
||||
assert.strictEqual(d.master, '4F3A');
|
||||
assert.strictEqual(d.iap, '4F09');
|
||||
assert.deepStrictEqual(d.links, [
|
||||
{ link: 1, tag: 'C0', kind: 'EF.ADN', fid: '4F3A', sfi: null },
|
||||
{ link: 1, tag: 'C1', kind: 'EF.IAP', fid: '4F09', sfi: null },
|
||||
{ link: 2, tag: 'C6', kind: 'EF.GRP', fid: '4F0B', sfi: '06' },
|
||||
{ link: 2, tag: 'C7', kind: 'EF.AAS', fid: '4F0C', sfi: '07' },
|
||||
]);
|
||||
assert.deepStrictEqual(d.files, ['4F3A', '4F09', '4F0B', '4F0C']);
|
||||
assert.strictEqual(d.note, undefined);
|
||||
});
|
||||
|
||||
test('EF.PBR: FF padding is not a bogus link; a truncated TLV is a note, not a throw', () => {
|
||||
const padded = efDecPbr(efBytes('A80AC0034F3A02C5034F0904AA0ACB034F3D07C2034F4A06' + 'FF'.repeat(8)));
|
||||
assert.strictEqual(padded.links.length, 4);
|
||||
assert.strictEqual(padded.note, undefined);
|
||||
assert.deepStrictEqual(padded.files, ['4F3A', '4F09', '4F3D', '4F4A']);
|
||||
// a trailing 'C5' tag with no length byte: the parsed links are kept
|
||||
const trunc = efDecPbr(efBytes('A80AC0034F3A02C5034F0904C5'));
|
||||
assert.strictEqual(trunc.master, '4F3A');
|
||||
assert.strictEqual(trunc.links.length, 2);
|
||||
assert.match(trunc.note, /truncated TLV/);
|
||||
// a container whose value runs past the record: the earlier links are kept
|
||||
const bad = efDecPbr(efBytes('A80AC0034F3A02C5034F0904AA0ACB034F3D07'));
|
||||
assert.strictEqual(bad.links.length, 2);
|
||||
assert.match(bad.note, /truncated TLV/);
|
||||
});
|
||||
|
||||
test('EF.PBR: unknown primitive tags are preserved raw with a note', () => {
|
||||
// A8 { C0 03 4F3A 02 (master), CE 03 4F09 04 (not a phonebook kind) }
|
||||
const d = efDecPbr(efBytes('A80AC0034F3A02CE034F0904'));
|
||||
assert.strictEqual(d.master, '4F3A');
|
||||
assert.deepStrictEqual(d.links, [
|
||||
{ link: 1, tag: 'C0', kind: 'EF.ADN', fid: '4F3A', sfi: '02' },
|
||||
{ link: 1, tag: 'CE', kind: null, fid: '4F09', sfi: '04', raw: '4F0904' },
|
||||
]);
|
||||
assert.deepStrictEqual(d.files, ['4F3A']);
|
||||
assert.match(d.note, /CE/);
|
||||
});
|
||||
|
||||
test('EF.PBR: the AA non-primitive fallback keeps the raw bytes and notes it', () => {
|
||||
const d = efDecPbr(efBytes('AA03C0FF01'));
|
||||
assert.deepStrictEqual(d.links, [
|
||||
{ link: 3, tag: 'AA', kind: null, fid: null, sfi: null, raw: 'C0FF01' },
|
||||
]);
|
||||
assert.match(d.note, /not a primitive list/);
|
||||
// an all-FF record is the unused marker
|
||||
assert.deepStrictEqual(efDecPbr(efBytes('FFFFFFFF')), { empty: true });
|
||||
});
|
||||
|
||||
test('EF.PBR matches the shared decode vectors (drift guard with the server parser)', () => {
|
||||
// frontend/tests/pbr_vectors.json is asserted by tests/test_pbr.py too:
|
||||
// the PWA decoder and the server-side PBR parser must agree.
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, 'pbr_vectors.json'), 'utf8'));
|
||||
assert.ok(vectors.cases.length >= 5);
|
||||
for (const c of vectors.cases) {
|
||||
const d = efDecPbr(efBytes(c.records[0]));
|
||||
assert.strictEqual(d.master, c.master, c.name + ': master');
|
||||
assert.deepStrictEqual(d.links, c.links, c.name + ': links');
|
||||
assert.deepStrictEqual(d.files, c.files, c.name + ': files');
|
||||
}
|
||||
});
|
||||
|
||||
test('EF.PBR is registered by name and FID; efFlatten renders link rows', () => {
|
||||
assert.strictEqual(efFindDecoder('EF.PBR', null).fid, '4f30');
|
||||
assert.strictEqual(efFindDecoder(null, '4f30').name, 'EF.PBR');
|
||||
assert.strictEqual(efFindDecoder('MF/7F10/5F3A/EF.PBR', null).fn, efDecPbr);
|
||||
assert.deepStrictEqual(efFlatten({ master: '4F3A', iap: '4F09', links: [
|
||||
{ link: 1, tag: 'C0', kind: 'EF.ADN', fid: '4F3A', sfi: '02' },
|
||||
{ link: 3, tag: 'AA', kind: null, fid: null, sfi: null, raw: 'C0FF01' },
|
||||
], files: ['4F3A'] }), [
|
||||
['Master EF', '4F3A'],
|
||||
['IAP (EF.IAP)', '4F09'],
|
||||
['Links 1', 'Type 1 \u00b7 EF.ADN \u2014 4F3A (SFI 02)'],
|
||||
['Links 2', 'Type 3 \u00b7 tag AA \u2014 C0FF01'],
|
||||
['Files', '4F3A'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('ADN-format records: alpha + BCD number + CCP/EXT1', () => {
|
||||
const d = efDecAdn(efBytes('42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF'));
|
||||
assert.strictEqual(d.name, 'B HA 1 Sic');
|
||||
assert.strictEqual(d.number, '6082658001');
|
||||
assert.strictEqual(d.ccp, undefined);
|
||||
assert.strictEqual(d.ext1, undefined);
|
||||
// international number in EF.MSISDN
|
||||
const m = efDecAdn(efBytes('ffffffffffffffffffffffffffffffffffffffff04b12143f5ffffffffffffffffff'));
|
||||
assert.strictEqual(m.name, '');
|
||||
assert.strictEqual(m.number, '12345');
|
||||
// EXT1 per TS 51.011 10.5.10: type 02 (additional data) + count 03 + BCD
|
||||
assert.deepStrictEqual(efDecExt1(efBytes('02032143F5' + 'FF'.repeat(8))), { number: '12345' });
|
||||
});
|
||||
|
||||
test('EF record decoders: SMS, OPL, DIR, ARR, IMPI/SMSP', () => {
|
||||
const opl = efDecOpl(efBytes('62f2100000fffe01'));
|
||||
assert.deepStrictEqual(opl, { mcc: '262', mnc: '01', lac_range: ['0000', 'FFFE'], pnn_record: 1 });
|
||||
const dir = efDecDir(efBytes('61294f10a0000000871002ffffffff890709000050055553696d31730ea00c80011781025f608203454150'));
|
||||
assert.strictEqual(dir.applications.length, 1);
|
||||
assert.strictEqual(dir.applications[0].aid, 'A0000000871002FFFFFFFF8907090000');
|
||||
assert.strictEqual(dir.applications[0].label, 'USim1');
|
||||
// EF.ARR: AM_DO 01 (read) guarded by ADM1 (TS 102 221 9.2.7 / ISO 7816-4 5.4.3.2)
|
||||
const arr = efDecArr(efBytes('800101a40683010a950108'));
|
||||
assert.deepStrictEqual(arr.rules, ['AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): ADM1 (verify)']);
|
||||
// Two rules: update needs PIN app1 or app2, read is always
|
||||
assert.deepStrictEqual(
|
||||
efDecArr(efBytes('800102a010a406830101950108a4068301029501088001019000')).rules,
|
||||
['AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): (PIN app1 (verify) OR PIN app2 (verify))',
|
||||
'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always']);
|
||||
// INCREASE / RESIZE ride AM_DO 0x84 with the command INS as value
|
||||
assert.deepStrictEqual(efDecArr(efBytes('8401329000')).rules,
|
||||
['INCREASE (AM_DO 0x84, INS 0x32): always']);
|
||||
assert.deepStrictEqual(efDecArr(efBytes('8401D49000')).rules,
|
||||
['RESIZE FILE (AM_DO 0x84, INS 0xD4): always']);
|
||||
// b8=1: bits 7-4 are proprietary
|
||||
assert.deepStrictEqual(efDecArr(efBytes('8001819000')).rules,
|
||||
['AM 0x81 (bits 7-4 proprietary + READ/SEARCH (EF) / DELETE FILE child (DF)): always']);
|
||||
const impi = efDecNai(efBytes('803137333830303630303030303031303140696d732e6d6e633030302e6d63633733382e336770706e6574776f726b2e6f7267'));
|
||||
assert.strictEqual(impi.text, '738006000000101@ims.mnc000.mcc738.3gppnetwork.org');
|
||||
const smsp = efDecSmsp(efBytes('534d5343ffffffffffffffffffffffffe1ffffffffffffffffffffffff0891945197109099f9ffffff0000a9'));
|
||||
assert.strictEqual(smsp.alpha, 'SMSC');
|
||||
assert.strictEqual(smsp.sca, '+4915790109999');
|
||||
assert.strictEqual(smsp.tp_pid, '0x00');
|
||||
assert.strictEqual(smsp.tp_dcs, '0x00');
|
||||
// EF.SMS status byte + skip SMSC + TPDU
|
||||
const sms = efDecSms(efBytes('030891945197109099f90000'));
|
||||
assert.strictEqual(sms.direction, 'MT');
|
||||
assert.strictEqual(sms.status, 'message to be read');
|
||||
assert.strictEqual(sms.tpdu, '0000');
|
||||
});
|
||||
|
||||
test('service tables: UST service bits and IST fallback for 6F07', () => {
|
||||
const ust = efDecServiceTable(efBytes('00000010'), EF_UST_SERVICES);
|
||||
assert.deepStrictEqual(ust.services, [{ n: 29, name: 'Data download via SMS-CB' }]);
|
||||
const ist = efDecImsi(efBytes('01'));
|
||||
assert.deepStrictEqual(ist.services, [{ n: 1, name: 'P-CSCF address' }]);
|
||||
});
|
||||
|
||||
test('decoder registry resolves by name first, then FID; unknown returns null', () => {
|
||||
assert.strictEqual(efFindDecoder('EF.IMSI', null).fid, '6f07');
|
||||
assert.strictEqual(efFindDecoder(null, '2fe2').name, 'EF.ICCID');
|
||||
assert.strictEqual(efFindDecoder('ADF.USIM/EF.LOCI', null).name, 'EF.LOCI');
|
||||
assert.strictEqual(efFindDecoder('EF.SUME', null).fid, '6f54');
|
||||
assert.strictEqual(efFindDecoder('EF.NOPE', 'ABCD'), null);
|
||||
// mixed-case registry names resolve case-insensitively without a FID
|
||||
assert.strictEqual(efFindDecoder('EF.HPLMNwAcT', null).fid, '6f62');
|
||||
assert.strictEqual(efFindDecoder('ef.plmnwact', null).name, 'EF.PLMNwAcT');
|
||||
assert.strictEqual(efFidFromPath('ADF.USIM/6F07'), '6F07');
|
||||
assert.strictEqual(efFidFromPath('MF/7F10/6F3A'), '6F3A');
|
||||
assert.strictEqual(efFidFromPath('ADF.USIM'), null);
|
||||
});
|
||||
|
||||
test('efDecodeFile handles transparent and record content', () => {
|
||||
const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' });
|
||||
assert.strictEqual(typeof t.fn, 'function');
|
||||
assert.strictEqual(t.data.imsi, '228062800000208');
|
||||
const r = efDecodeFile('EF.ADN', '6f3a', { kind: 'record', records: [
|
||||
{ num: 1, data: '42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF' },
|
||||
{ num: 2, data: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' },
|
||||
] });
|
||||
assert.strictEqual(r.records.length, 2);
|
||||
assert.strictEqual(r.records[0].data.name, 'B HA 1 Sic');
|
||||
assert.strictEqual(r.records[0].empty, false);
|
||||
assert.strictEqual(r.records[1].empty, true);
|
||||
assert.strictEqual(efDecodeFile('EF.NOPE', null, { kind: 'transparent', data: '00' }), null);
|
||||
});
|
||||
|
||||
test('efFlatten renders fields, services and PLMNs for display', () => {
|
||||
assert.deepStrictEqual(efFlatten({ imsi: '123' }), [['IMSI', '123']]);
|
||||
assert.deepStrictEqual(efFlatten({ services: [{ n: 5, name: 'Call Control by USIM' }] }),
|
||||
[['Service 5', 'Call Control by USIM']]);
|
||||
assert.deepStrictEqual(efFlatten({ plmns: [{ mcc: '262', mnc: '01', access_tech: 'GSM' }] }),
|
||||
[['PLMN 1', '262-01 (GSM)']]);
|
||||
assert.deepStrictEqual(efFlatten({ show_in_hplmn: true }), [['Show in HPLMN', 'yes']]);
|
||||
assert.deepStrictEqual(efFlatten({ title: 'Menu!', icon: '0123' }), [['Title', 'Menu!'], ['Icon', '0123']]);
|
||||
});
|
||||
|
||||
test('efDiffData reports only differing fields; efContentDiff decodes both sides', () => {
|
||||
assert.deepStrictEqual(efDiffData({ imsi: '1', kc: 'AA' }, { imsi: '2', kc: 'AA' }), [['IMSI', '1', '2']]);
|
||||
assert.deepStrictEqual(efDiffData({ imsi: '1' }, { imsi: '1' }), []);
|
||||
const dd = efContentDiff('EF.IMSI', null, '082982608200002080', '082982608200002081');
|
||||
assert.ok(dd && dd.rows.length === 1);
|
||||
assert.strictEqual(dd.rows[0][0], 'IMSI');
|
||||
assert.strictEqual(dd.rows[0][1], '228062800000208');
|
||||
const same = efContentDiff('EF.IMSI', null, '082982608200002080', '092982608200002080');
|
||||
assert.ok(same && same.sameDecoded);
|
||||
assert.strictEqual(efContentDiff('EF.NOPE', null, '00', '01'), null);
|
||||
});
|
||||
|
||||
test('efFlatten skips the all-FF empty marker', () => {
|
||||
assert.deepStrictEqual(efFlatten({ empty: true }), []);
|
||||
});
|
||||
|
||||
test('efDiffData names an all-FF side Empty (all FF)', () => {
|
||||
assert.deepStrictEqual(efDiffData({ empty: true }, { full: 'Miran' }),
|
||||
[['Full name', 'Empty (all FF)', 'Miran']]);
|
||||
assert.deepStrictEqual(efDiffData({ full: 'Miran' }, { empty: true }),
|
||||
[['Full name', 'Miran', 'Empty (all FF)']]);
|
||||
assert.deepStrictEqual(efDiffData({ empty: true }, { empty: true }), []);
|
||||
// a field merely missing from a non-empty record stays blank
|
||||
assert.deepStrictEqual(efDiffData({ full: 'Miran' }, { full: 'Miran', short: 'Mir' }),
|
||||
[['Short name', '', 'Mir']]);
|
||||
});
|
||||
|
||||
test('efContentDiff reports an empty PNN record side', () => {
|
||||
const expected = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF';
|
||||
const actual = '430887CDB43CEC2687FFFFFFFFFFFFFFFFFFFFFF';
|
||||
assert.deepStrictEqual(efDecPnn(efBytes(actual)), { full: 'Miran' });
|
||||
const dd = efContentDiff('EF.PNN', '6fc5', expected, actual);
|
||||
assert.ok(dd, 'decoded diff expected');
|
||||
assert.deepStrictEqual(dd.rows, [['Full name', 'Empty (all FF)', 'Miran']]);
|
||||
});
|
||||
|
||||
test('efDataSummary produces one-line summaries', () => {
|
||||
assert.strictEqual(efDataSummary(efDecImsi(efBytes('082982608200002080'))), 'IMSI 228062800000208');
|
||||
assert.strictEqual(efDataSummary(efDecIccid(efBytes('988812010000400310f0'))), 'ICCID 8988211000000430010');
|
||||
assert.strictEqual(efDataSummary(efDecSume(efBytes('05054D656E7521FFFFFFFF'))), 'Menu!');
|
||||
assert.strictEqual(efDataSummary({ plmns: [{ mcc: '262', mnc: '01' }] }), '262-01');
|
||||
});
|
||||
|
||||
test('file manager decoded view renders fields, not JSON', () => {
|
||||
const out = pysimFsDecodedHtml({ name: 'EF.IMSI', fid: null }, { success: true, data: '082982608200002080' });
|
||||
assert.match(out, /IMSI/);
|
||||
assert.match(out, /228062800000208/);
|
||||
assert.match(out, /pySim JSON \(server\)/);
|
||||
assert.doesNotMatch(out, /\{"imsi"/);
|
||||
// the label column is content-sized, not a fixed 10rem block
|
||||
assert.match(out, /grid-template-columns:max-content 1fr/);
|
||||
assert.doesNotMatch(out, /w-40/);
|
||||
// no decoder -> raw fallback with a note
|
||||
const raw = pysimFsDecodedHtml({ name: 'EF.NOPE', fid: 'abcd' }, { success: true, data: '0011' });
|
||||
assert.match(raw, /No decoder for this file/);
|
||||
assert.match(raw, /0011/);
|
||||
});
|
||||
|
||||
test('efRenderFieldsHtml sizes the label column to its content', () => {
|
||||
const out = efRenderFieldsHtml([['ICCID', '8988'], ['Very long label', 'x']]);
|
||||
assert.match(out, /style="display:grid;grid-template-columns:max-content 1fr/);
|
||||
assert.doesNotMatch(out, /w-40/);
|
||||
assert.ok(out.includes('>ICCID</span>'));
|
||||
assert.ok(out.includes('>8988</span>'));
|
||||
assert.ok(out.includes('>Very long label</span>'));
|
||||
assert.match(efRenderFieldsHtml([]), /No decodable fields/);
|
||||
});
|
||||
|
||||
test('profilerRenderReport shows raw and decoded comparisons for content mismatches', () => {
|
||||
const res = {
|
||||
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
|
||||
checks: [{ label: 'content', expected: '082982608200002080', actual: '082982608200002081', ok: false }],
|
||||
};
|
||||
const out = profilerRenderReport([res], null);
|
||||
// the raw pair is always shown
|
||||
assert.match(out, /Raw comparison/);
|
||||
assert.ok(out.includes('value="082982608200002080"'));
|
||||
assert.ok(out.includes('value="082982608200002081"'));
|
||||
// the decoded per-field diff follows, with expected/actual column headers
|
||||
assert.match(out, /Decoded comparison/);
|
||||
assert.match(out, /IMSI/);
|
||||
assert.match(out, /228062800000208/);
|
||||
assert.match(out, /228062800000218/);
|
||||
assert.match(out, /<span class="flex-1 min-w-0 text-xs text-gray-500 dark:text-slate-400">expected<\/span><span class="flex-1 min-w-0 text-xs text-gray-500 dark:text-slate-400">actual<\/span>/);
|
||||
// custom comparison labels are used for the raw rows and the decoded headers
|
||||
const named = profilerRenderReport([res], { expected: 'master', actual: 'checked' });
|
||||
assert.ok(named.includes('>master</span>'));
|
||||
assert.ok(named.includes('>checked</span>'));
|
||||
// matching decoded values -> note instead of the decoded table, raw pair kept
|
||||
const same = profilerRenderReport([{
|
||||
path: 'ADF.USIM/6F07', name: 'EF.IMSI', status: 'fail',
|
||||
checks: [{ label: 'content', expected: '082982608200002080', actual: '092982608200002080', ok: false }],
|
||||
}], null);
|
||||
assert.match(same, /Raw comparison/);
|
||||
assert.match(same, /Decoded values match/);
|
||||
assert.doesNotMatch(same, /Decoded comparison/);
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText',
|
||||
'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimIconDataUrl',
|
||||
'esimChipSectionRows', 'esimChipValueWide', 'esimChipBox', 'esimSwitchStatus',
|
||||
'esimProfileTitle']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = \\{[\\s\\S]*?\\n\\};'))[0]
|
||||
.replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
code += 'function esc(s){return String(s);}\n';
|
||||
eval(code);
|
||||
|
||||
test('esimGroupEid groups the hex digits in fours', () => {
|
||||
assert.strictEqual(esimGroupEid('89049032000000000000000000000001'),
|
||||
'8904 9032 0000 0000 0000 0000 0000 0001');
|
||||
assert.strictEqual(esimGroupEid(''), '');
|
||||
});
|
||||
|
||||
test('esimLabel maps known chip keys and prettifies the rest', () => {
|
||||
assert.strictEqual(esimLabel('svn'), 'SVN');
|
||||
assert.strictEqual(esimLabel('default_dp_address'), 'Default SM-DP+ address');
|
||||
assert.strictEqual(esimLabel('installed_application'), 'Installed applications');
|
||||
assert.strictEqual(esimLabel('forbidden_profile_policy_rules'), 'Forbidden PPRs');
|
||||
assert.strictEqual(esimLabel('allowed_operators'), 'Allowed operators');
|
||||
assert.strictEqual(esimLabel('some_unknown_key'), 'Some unknown key');
|
||||
});
|
||||
|
||||
test('esimFieldRows flattens nested values and skips empties', () => {
|
||||
const rows = esimFieldRows({
|
||||
svn: '1.2.3', empty: null, blank: '', nested: { profile_version: '2.2' },
|
||||
list: ['a', 'b'], zero: 0,
|
||||
});
|
||||
assert.deepStrictEqual(rows, [
|
||||
['SVN', '1.2.3'],
|
||||
['Nested / Profile version', '2.2'],
|
||||
['List', 'a, b'],
|
||||
['Zero', '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('esimFieldRows labels nested keys per path component', () => {
|
||||
const rows = esimFieldRows({
|
||||
euicc_ci_pki_list_for_verification: { subject_key_identifier: '8137AB' },
|
||||
ext_card_resource: {
|
||||
installed_application: 0,
|
||||
free_non_volatile_memory: 439084,
|
||||
},
|
||||
}, '');
|
||||
assert.deepStrictEqual(rows, [
|
||||
['CI PKI (verification) / Subject key identifier', '8137AB'],
|
||||
['Card resource / Installed applications', '0'],
|
||||
['Card resource / Free non-volatile memory', '439084'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('esimFieldRows recurses into arrays of objects with index labels', () => {
|
||||
const rows = esimFieldRows({
|
||||
rat: [{
|
||||
ppr_ids: ['ppr1', 'ppr2'],
|
||||
allowed_operators: [{ plmn: 'EEEEEE', gid1: null, gid2: null }],
|
||||
ppr_flags: ['consentRequired'],
|
||||
}],
|
||||
}, '');
|
||||
assert.deepStrictEqual(rows, [
|
||||
['Rules authorisation table 1 / PPR IDs', 'ppr1, ppr2'],
|
||||
['Rules authorisation table 1 / Allowed operators 1 / PLMN', 'EEEEEE'],
|
||||
['Rules authorisation table 1 / PPR flags', 'consentRequired'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('esimResultText localizes result codes and passes errors through', () => {
|
||||
assert.strictEqual(esimResultText({ result: 'catBusy' }), 'card is busy with a CAT session');
|
||||
assert.strictEqual(esimResultText({ result: 'ok' }), 'ok');
|
||||
assert.strictEqual(esimResultText({ error: 'no card' }), 'no card');
|
||||
assert.strictEqual(esimResultText(null), '');
|
||||
});
|
||||
|
||||
test('esimSwitchStatus reports the switch, re-init, REFRESH and verification', () => {
|
||||
assert.strictEqual(esimSwitchStatus({ ok: true }), 'Profile switched');
|
||||
assert.strictEqual(
|
||||
esimSwitchStatus({ ok: true, reinitialized: true, refresh_seen: true }),
|
||||
'Profile switched — card re-initialized (REFRESH received)');
|
||||
assert.strictEqual(
|
||||
esimSwitchStatus({ ok: true, reinitialized: true, verified: false, state_after: 'disabled' }),
|
||||
'Profile switched — card re-initialized — not confirmed (Disabled)');
|
||||
assert.strictEqual(esimSwitchStatus({ ok: false, result: 'catBusy' }),
|
||||
'Profile switch failed: card is busy with a CAT session');
|
||||
assert.strictEqual(esimSwitchStatus({ ok: false, error: 'SW 6985' }),
|
||||
'Profile switch failed: SW 6985');
|
||||
});
|
||||
|
||||
test('esimStateLabel and esimOperationsText map the profile metadata', () => {
|
||||
assert.strictEqual(esimStateLabel('enabled'), 'Enabled');
|
||||
assert.strictEqual(esimStateLabel('disabled'), 'Disabled');
|
||||
assert.strictEqual(esimStateLabel('other'), 'other');
|
||||
assert.strictEqual(esimOperationsText(['enable', 'delete']), 'enable, delete');
|
||||
assert.strictEqual(esimOperationsText([]), '');
|
||||
});
|
||||
|
||||
test('esimProfileRows renders the metadata in a stable order', () => {
|
||||
const rows = esimProfileRows({
|
||||
iccid: '8970119000004002667', isdp_aid: 'A0000005591010FFFFFFFF8900000100',
|
||||
provider: 'Miranda', name: 'Miranda LTE', class: 'operational',
|
||||
owner: '250-99', icon_type: 'png', icon_size: 1234,
|
||||
});
|
||||
assert.deepStrictEqual(rows.map(r => r[0]),
|
||||
['ICCID', 'ISD-P AID', 'Provider', 'Profile name', 'Class', 'Owner', 'Icon']);
|
||||
assert.strictEqual(rows[0][1], '8970119000004002667');
|
||||
assert.strictEqual(rows[rows.length - 1][1], 'png · 1234 B');
|
||||
});
|
||||
|
||||
test('esimProfileRows shows the bare icon type when the card sent no image', () => {
|
||||
assert.deepStrictEqual(esimProfileRows({ icon_type: 'jpg' }), [['Icon', 'jpg']]);
|
||||
});
|
||||
|
||||
test('esimIconDataUrl builds a data URL for png and jpg icons', () => {
|
||||
assert.strictEqual(esimIconDataUrl({ icon_type: 'png', icon: '89504E47' }),
|
||||
'data:image/png;base64,iVBORw==');
|
||||
assert.strictEqual(esimIconDataUrl({ icon_type: 'jpg', icon: 'FFD8' }),
|
||||
'data:image/jpeg;base64,/9g=');
|
||||
assert.strictEqual(esimIconDataUrl({ icon_type: 'png' }), '');
|
||||
assert.strictEqual(esimIconDataUrl({ icon: '89504E47' }), '');
|
||||
assert.strictEqual(esimIconDataUrl(null), '');
|
||||
});
|
||||
|
||||
test('esimProfileTitle puts the provider in front of the profile label', () => {
|
||||
assert.strictEqual(esimProfileTitle({ provider: 'Alfa', name: '0002' }), 'Alfa 0002');
|
||||
assert.strictEqual(esimProfileTitle({ provider: 'Alfa', nickname: 'Work', name: '0002' }),
|
||||
'Alfa Work');
|
||||
assert.strictEqual(esimProfileTitle({ name: '0002' }), '0002');
|
||||
assert.strictEqual(esimProfileTitle({ provider: 'Alfa' }), 'Alfa');
|
||||
assert.strictEqual(esimProfileTitle({ iccid: '8970119000004002667' }),
|
||||
'8970119000004002667');
|
||||
assert.strictEqual(esimProfileTitle({}), '?');
|
||||
});
|
||||
|
||||
test('the profile card renders the icon image next to the rows', () => {
|
||||
const fn = extractFunc(html, 'esimRenderProfiles');
|
||||
assert.match(fn, /esimIconDataUrl\(p\)/);
|
||||
assert.match(fn, /<img src=/);
|
||||
});
|
||||
|
||||
test('esimChipSectionRows builds rows for objects and RAT rule lists', () => {
|
||||
assert.deepStrictEqual(esimChipSectionRows({ svn: '2.2.2' }), [['SVN', '2.2.2']]);
|
||||
assert.deepStrictEqual(esimChipSectionRows(null), []);
|
||||
assert.deepStrictEqual(esimChipSectionRows([{ ppr_ids: ['ppr1'] }]),
|
||||
[['Rule 1 / PPR IDs', 'ppr1']]);
|
||||
});
|
||||
|
||||
test('esimChipBox renders a titled box and skips empty sections', () => {
|
||||
assert.strictEqual(esimChipBox('EUICCInfo1', []), '');
|
||||
assert.strictEqual(esimChipBox('EUICCInfo1', null), '');
|
||||
const box = esimChipBox('EUICCInfo1', [['SVN', '2.2.2'], ['', 'raw-value']]);
|
||||
assert.match(box, /EUICCInfo1/);
|
||||
assert.match(box, /SVN/);
|
||||
assert.match(box, /2\.2\.2/);
|
||||
assert.match(box, /raw-value/);
|
||||
});
|
||||
|
||||
test('the chip boxes prefer wrapping at spaces over breaking words', () => {
|
||||
const box = esimChipBox('EUICCInfo1', [['SVN', '2.2.2']]);
|
||||
assert.match(box, /break-words/);
|
||||
assert.doesNotMatch(box, /break-all/);
|
||||
const render = extractFunc(html, 'esimRenderProfiles');
|
||||
assert.doesNotMatch(render, /break-all/);
|
||||
});
|
||||
|
||||
test('esimChipValueWide keeps short scalars in one column', () => {
|
||||
assert.strictEqual(esimChipValueWide('Profile version', '2.3.1'), false);
|
||||
assert.strictEqual(
|
||||
esimChipValueWide('Card resource / Free non-volatile memory', '439084'), false);
|
||||
assert.strictEqual(esimChipValueWide('SS accreditation number', 'ED-ZI-UP-0826'), false);
|
||||
assert.strictEqual(
|
||||
esimChipValueWide('RSP capability', 'additionalProfile, testProfileSupport'), true);
|
||||
assert.strictEqual(
|
||||
esimChipValueWide('CI PKI (verification) / Subject key identifier',
|
||||
'81370F5125D0B1D408D4C3B232E6D25E795BEBFB'), true);
|
||||
});
|
||||
|
||||
test('esimChipBox lays short fields out in two columns', () => {
|
||||
const box = esimChipBox('EUICCInfo2', [
|
||||
['Profile version', '2.3.1'], ['SVN', '2.2.2'],
|
||||
['UICC capability', 'usimSupport, isimSupport'],
|
||||
], true);
|
||||
assert.match(box, /sm:grid-cols-2/);
|
||||
const cells = box.match(/<div class="flex gap-2[^"]*"/g);
|
||||
assert.deepStrictEqual(cells, [
|
||||
'<div class="flex gap-2 min-w-0"',
|
||||
'<div class="flex gap-2 min-w-0"',
|
||||
'<div class="flex gap-2 min-w-0 sm:col-span-2"',
|
||||
]);
|
||||
const one = esimChipBox('EUICCInfo1', [['SVN', '2.2.2']]);
|
||||
assert.doesNotMatch(one, /sm:grid-cols-2/);
|
||||
assert.match(one, /flex gap-2 mb-0\.5/);
|
||||
});
|
||||
|
||||
test('the chip view renders EUICCInfo2 in two columns', () => {
|
||||
const fn = extractFunc(html, 'esimRenderChip');
|
||||
assert.match(fn, /esimChipBox\('EUICCInfo2', esimChipSectionRows\(c\.info2\), true\)/);
|
||||
});
|
||||
|
||||
test('the chip view groups the sections into a grid', () => {
|
||||
const fn = extractFunc(html, 'esimRenderChip');
|
||||
assert.match(fn, /lg:grid-cols-3/);
|
||||
assert.match(fn, /lg:col-span-2/);
|
||||
assert.match(fn, /esimChipBox\('EUICCInfo2'/);
|
||||
assert.match(fn, /esimChipSectionRows/);
|
||||
});
|
||||
|
||||
test('the eSIM LPA pill is wired into the Simulator tab', () => {
|
||||
assert.ok(html.includes('data-phone-sub="esim"'));
|
||||
assert.ok(html.includes('id="phone-sub-esim"'));
|
||||
assert.ok(html.includes("'phone', 'tr', 'bip', 'esim'"));
|
||||
assert.ok(html.includes('id="esim-refresh-btn"'));
|
||||
assert.ok(html.includes('id="esim-profiles"'));
|
||||
assert.ok(html.includes('id="esim-notifications"'));
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'cmdQualifierShort'));
|
||||
eval(extractBlock('const EVENT_ALIASES = {', 'function eventCode').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'eventCode'));
|
||||
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_INNER_MAX = ', 'function pysimEventSendForm').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'eventFieldsForRelease'));
|
||||
eval(extractFunc(html, 'encPlmn'));
|
||||
eval(extractFunc(html, 'bytesToHex'));
|
||||
eval(extractFunc(html, 'esc'));
|
||||
eval(extractFunc(html, 'eventFormFieldsHtml'));
|
||||
|
||||
test('CMD_NAMES decodes timer management and the BIP commands', () => {
|
||||
assert.strictEqual(CMD_NAMES['01'], 'REFRESH');
|
||||
assert.strictEqual(CMD_NAMES['10'], 'SET UP CALL');
|
||||
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
|
||||
assert.strictEqual(CMD_NAMES['28'], 'SET UP IDLE MODE TEXT');
|
||||
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
|
||||
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
|
||||
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
|
||||
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
|
||||
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
|
||||
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
|
||||
});
|
||||
|
||||
test('cmdQualifierShort returns empty for unknown types', () => {
|
||||
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
|
||||
});
|
||||
|
||||
test('EVENT_NAMES follows the pinned CAT spec and names the 3GPP events', () => {
|
||||
assert.strictEqual(EVENT_NAMES[0x0B], 'Access technology change (single access technology)');
|
||||
assert.strictEqual(EVENT_NAMES[0x14], 'Access technology change (multiple access technologies)');
|
||||
assert.strictEqual(EVENT_NAMES[0x19], 'Profile container');
|
||||
assert.strictEqual(EVENT_NAMES[0x1C], 'Poll interval negotiation');
|
||||
for (const [v, name] of [[0x11, '(I-)WLAN access status'], [0x12, 'Network rejection'],
|
||||
[0x15, 'CSG cell selection'], [0x17, 'IMS registration'], [0x18, 'Incoming IMS data'],
|
||||
[0x1D, 'Data connection status change'], [0x1E, 'CAG cell selection'],
|
||||
[0x1F, 'Slices status change'], [0x20, 'Reserved (future usage)']]) {
|
||||
assert.strictEqual(EVENT_NAMES[v], name, '0x' + v.toString(16));
|
||||
}
|
||||
// the "Reserved for 3GPP" spec cross-reference is never user-facing
|
||||
for (const [v, name] of Object.entries(EVENT_NAMES)) {
|
||||
assert.ok(!name.includes('Reserved for 3GPP'), v + ': ' + name);
|
||||
}
|
||||
});
|
||||
|
||||
test('the poll interval negotiation event builds a Duration TLV', () => {
|
||||
const build = EVENT_FORMS[0x1C].build;
|
||||
assert.strictEqual(build({ unit: '1', interval: '45' }), '0402012D');
|
||||
assert.strictEqual(build({ unit: '0', interval: '2' }), '04020002');
|
||||
assert.strictEqual(build({ unit: '2', interval: '5' }), '04020205');
|
||||
assert.strictEqual(build({ unit: '1', interval: '' }), '0402011E'); // default 30 s
|
||||
});
|
||||
|
||||
test('channel status event builds the B8 channel status TLV', () => {
|
||||
const build = EVENT_FORMS[0x0A].build;
|
||||
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
|
||||
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
|
||||
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
|
||||
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
|
||||
});
|
||||
|
||||
test('the data connection status change event builds the 7.5.25 object set', () => {
|
||||
const build = EVENT_FORMS[0x1D].build;
|
||||
assert.strictEqual(EVENT_FORMS[0x1D].note, undefined);
|
||||
// the four mandatory objects; every conditional object omitted
|
||||
assert.strictEqual(build({ status: '0', type: '0', ti: '00', loc_status: '0' }),
|
||||
'9D0100AA01001C01009B0100');
|
||||
// full form: cause, location information, access technology, APN, type
|
||||
assert.strictEqual(
|
||||
build({ status: '1', type: '2', cause: '26', ti: '85', mcc: '250', mnc: '01',
|
||||
lac: '00FF', cell: '0001', tech: '8', loc_status: '1', apn: 'internet', pdp_type: '3' }),
|
||||
'9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101C708696E7465726E65740B0103');
|
||||
// an empty TI falls back to 00; the location defaults fill missing fields
|
||||
assert.strictEqual(build({ status: '2', type: '1', ti: '', mcc: '250', loc_status: '2' }),
|
||||
'9D0102AA01011C0100130752F010000000019B0102');
|
||||
});
|
||||
|
||||
test('the access technology options follow the pinned 8.61 coding', () => {
|
||||
const opts = EVENT_FORMS[0x0B].fields[0].opts;
|
||||
assert.deepStrictEqual(opts.map(o => o.v), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
assert.strictEqual(opts[1].l, 'TIA/EIA-553-A');
|
||||
assert.strictEqual(opts[2].l, 'TIA/EIA-136-270');
|
||||
assert.strictEqual(opts[10].l, 'NG-RAN');
|
||||
assert.strictEqual(opts[12].l, 'Satellite E-UTRAN');
|
||||
});
|
||||
|
||||
test('eventDateTimeTlv builds the 8.39 date-time object', () => {
|
||||
const d = o => ({
|
||||
getFullYear: () => o.year, getMonth: () => o.month - 1, getDate: () => o.day,
|
||||
getHours: () => o.hour, getMinutes: () => o.min, getSeconds: () => o.sec,
|
||||
getTimezoneOffset: () => o.tzMin,
|
||||
});
|
||||
// UTC+3, 2026-09-30 21:55:00 -> swapped BCD + 12 quarters
|
||||
assert.strictEqual(
|
||||
eventDateTimeTlv(d({ year: 2026, month: 9, day: 30, hour: 21, min: 55, sec: 0, tzMin: -180 })),
|
||||
'260762900312550021');
|
||||
// UTC-2:30 -> 10 quarters with the sign bit (bit 3 of the first semi-octet)
|
||||
assert.strictEqual(
|
||||
eventDateTimeTlv(d({ year: 2026, month: 1, day: 5, hour: 8, min: 7, sec: 9, tzMin: 150 })),
|
||||
'260762105080709009');
|
||||
});
|
||||
|
||||
test('the data connection event can carry the current date-time', () => {
|
||||
const build = EVENT_FORMS[0x1D].build;
|
||||
assert.match(build({ status: '0', type: '0', ti: '00', loc_status: '0', datetime: 'now' }),
|
||||
/^9D0100AA01001C01002607[0-9A-F]{14}9B0100$/);
|
||||
// left out by default
|
||||
assert.strictEqual(build({ status: '0', type: '0', ti: '00', loc_status: '0' }),
|
||||
'9D0100AA01001C01009B0100');
|
||||
});
|
||||
|
||||
test('encBcdDigits and eventAddressTlv build the EFADN number coding', () => {
|
||||
assert.strictEqual(encBcdDigits('12345'), '2143F5');
|
||||
assert.strictEqual(encBcdDigits('79001234567'), '9700214365F7');
|
||||
// international number -> TON/NPI 0x91, length = 1 + 6 bytes
|
||||
assert.strictEqual(eventAddressTlv('79001234567', '1'), '8607919700214365F7');
|
||||
assert.strictEqual(eventAddressTlv('', '1'), '');
|
||||
});
|
||||
|
||||
test('the call events build their 31.111 object sets', () => {
|
||||
// MT call: source network, TI + Address + Subaddress + IMS URI + Media Type
|
||||
assert.strictEqual(EVENT_FORMS[0x00].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x00].build({ ti: '85', number: '79001234567', ton: '1',
|
||||
sub: '12', ims_uri: 'sip:a@b', media: '1' }),
|
||||
'1C01858607919700214365F7880112B1077369703A614062FE0101');
|
||||
// call connected: source is the form's field, TI + optional Media Type
|
||||
assert.strictEqual(EVENT_FORMS[0x01].srcField, 'src');
|
||||
assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '0' }), '1C0100FE0100');
|
||||
assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '' }), '1C0100');
|
||||
// call disconnected: radio link timeout = zero-length cause, custom = bytes
|
||||
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'rlt', media: '' }), '1C01009A00');
|
||||
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'custom', cause: '10', media: '' }), '1C01009A0110');
|
||||
// a URI longer than the 100-byte limit is cut and tagged (8.135)
|
||||
const long = EVENT_FORMS[0x00].build({ ti: '00', ims_uri: 'x'.repeat(120) });
|
||||
assert.ok(long.includes('B164' + '78'.repeat(100)), 'the URI is cut to 100 bytes');
|
||||
assert.ok(long.endsWith('F300'), long.slice(-8));
|
||||
assert.ok(!EVENT_FORMS[0x00].build({ ti: '00', ims_uri: 'sip:a@b' }).includes('F300'));
|
||||
});
|
||||
|
||||
test('the browser, WLAN and CSG events build their object sets', () => {
|
||||
assert.strictEqual(EVENT_FORMS[0x08].build({ cause: '1' }), 'B40101');
|
||||
assert.strictEqual(EVENT_FORMS[0x11].build({ status: '2' }), 'CB0102');
|
||||
// CSG: source network, access tech + status/mechanism + ID + name + PLMN
|
||||
assert.strictEqual(EVENT_FORMS[0x15].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x15].build({ status: '1', mech: 'manual', tech: '8',
|
||||
csg: '00000001', hnb: 'MyCSG', mcc: '250', mnc: '01' }),
|
||||
'BF0108D5020141D60400000001D7054D79435347890352F010');
|
||||
});
|
||||
|
||||
test('every event type has a parameter form now', () => {
|
||||
assert.ok(!html.includes("note: 'not_yet'"), 'no event may stay unimplemented');
|
||||
});
|
||||
|
||||
test('the data/display/search/frames events build their object sets', () => {
|
||||
// Data available: Channel status (B8) + Channel data length (B7)
|
||||
assert.strictEqual(EVENT_FORMS[0x09].build({ channel: '2', state: '128', info: '0', len: 'FF' }),
|
||||
'B8028200B701FF');
|
||||
// Display parameters: rows/cols + sizing/fonts/effects bytes
|
||||
assert.strictEqual(EVENT_FORMS[0x0C].build({ rows: '4', cols: '16', sizing: '1', fonts: '0', effects: '00' }),
|
||||
'C003841000');
|
||||
assert.strictEqual(EVENT_FORMS[0x0E].build({ mode: '1' }), 'E50101');
|
||||
assert.strictEqual(EVENT_FORMS[0x0F].build({ status: '12' }), 'E40112');
|
||||
assert.strictEqual(EVENT_FORMS[0x0F].build({ status: '' }), 'E40100');
|
||||
assert.strictEqual(EVENT_FORMS[0x10].build({ frame: '01', list: '0410' }), 'E703010410');
|
||||
// Card reader status (8.33) and Language (8.45) carry their object tags
|
||||
assert.strictEqual(EVENT_FORMS[0x06].build({ status: '1' }), 'A00101');
|
||||
assert.strictEqual(EVENT_FORMS[0x07].build({ lang: 'en' }), 'AD02656E');
|
||||
});
|
||||
|
||||
test('the IMS, CAG and slices events build their object sets', () => {
|
||||
// IMS registration: source network, IMPU list (80-tagged URIs) + status code
|
||||
assert.strictEqual(EVENT_FORMS[0x17].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x17].build({ impu: 'sip:a@b', code: '200' }),
|
||||
'F70980077369703A614062F803323030');
|
||||
assert.strictEqual(EVENT_FORMS[0x18].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x18].build({ iari: 'a' }), 'F60161');
|
||||
// CAG cell selection: tech + status/mechanism + information list
|
||||
assert.strictEqual(EVENT_FORMS[0x1E].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x1E].build({ status: '1', mech: 'manual', tech: '8', list: '00000001' }),
|
||||
'BF0108D5020141D60400000001');
|
||||
// Slices status change: tech + status + served S-NSSAIs (count + 4 bytes each)
|
||||
assert.strictEqual(EVENT_FORMS[0x1F].src, '83');
|
||||
assert.strictEqual(EVENT_FORMS[0x1F].build({ status: '1', tech: '8', served: '01020304' }),
|
||||
'BF0108D50101D6050101020304');
|
||||
// partial NSSAI: the F9 object + the automatic Last Envelope marker
|
||||
assert.strictEqual(EVENT_FORMS[0x1F].build({ status: '1', partial: '0102' }), 'D50101F9020102F000');
|
||||
assert.ok(!EVENT_FORMS[0x1F].build({ status: '1', served: '01020304' }).includes('F000'));
|
||||
});
|
||||
|
||||
test('the location-status event can carry the location information', () => {
|
||||
const build = EVENT_FORMS[0x03].build;
|
||||
// Normal service: the 8.19 object follows the status
|
||||
assert.strictEqual(build({ status: '0', mcc: '250', mnc: '01', lac: '00FF', cell: '0001' }),
|
||||
'9B0100930752F01000FF0001');
|
||||
// Limited / No service: no location information
|
||||
assert.strictEqual(build({ status: '1', mcc: '250' }), '9B0101');
|
||||
assert.strictEqual(build({ status: '0' }), '9B0100');
|
||||
});
|
||||
|
||||
test('the channel-status event can carry the bearer and the local address', () => {
|
||||
const build = EVENT_FORMS[0x0A].build;
|
||||
assert.strictEqual(build({ channel: '1', state: '128', info: '0' }), 'B8028100');
|
||||
assert.strictEqual(build({ channel: '1', state: '128', info: '0', bearer: '5', addr_type: '21', addr: '192.168.1.2' }),
|
||||
'B8028100B50105BE0521C0A80102');
|
||||
assert.strictEqual(build({ channel: '1', state: '128', info: '0', addr_type: '00' }), 'B8028100BE00');
|
||||
});
|
||||
|
||||
test('the disconnected/rejection/CAG/slices events carry their extra objects', () => {
|
||||
// IMS call disconnection cause: protocol + 2-byte cause (603 -> 025B)
|
||||
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: '', media: '', ims_proto: '1', ims_cause: '603' }),
|
||||
'1C0100D50301025B');
|
||||
// Extended information (CAG ID, 4 bytes)
|
||||
assert.ok(EVENT_FORMS[0x12].build({ reg_type: '9', access_tech: '8', cause: '2', ext_info_type: '1', ext_info: '00000001' })
|
||||
.endsWith('F2050100000001'));
|
||||
// CAG HRNN list (80-tagged names)
|
||||
assert.ok(EVENT_FORMS[0x1E].build({ status: '1', list: '', hrnn: 'A,B' }).endsWith('D706800141800142'));
|
||||
// allowed/served/rejected slice lists, with and without mapping
|
||||
assert.ok(EVENT_FORMS[0x1F].build({ status: '1', served: '', allowed_map: '01020304', allowed: '01020304',
|
||||
rejected_map: '01020304', rejected: '01020304' })
|
||||
.endsWith('F70401020304F80401020304D70401020304B10401020304'));
|
||||
});
|
||||
|
||||
test('event fields introduced after the effective release are hidden', () => {
|
||||
// MT call: the IMS URI came with the Rel-12 URI support (12.4.0)
|
||||
let r = eventFieldsForRelease(EVENT_FORMS[0x00], 11);
|
||||
assert.deepStrictEqual(r.hidden.map(f => f.id), ['ims_uri']);
|
||||
assert.ok(r.fields.some(f => f.id === 'ti'));
|
||||
assert.strictEqual(eventFieldsForRelease(EVENT_FORMS[0x00], 12).hidden.length, 0);
|
||||
// CSG cell selection: the PLMN ID came in 12.1.0
|
||||
r = eventFieldsForRelease(EVENT_FORMS[0x15], 11);
|
||||
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['mcc', 'mnc']);
|
||||
// IMS registration: the IMPU list and the status code came in Rel-11
|
||||
r = eventFieldsForRelease(EVENT_FORMS[0x17], 10);
|
||||
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['code', 'impu']);
|
||||
// slices: the mapping/rejected objects came in Rel-18
|
||||
r = eventFieldsForRelease(EVENT_FORMS[0x1F], 16);
|
||||
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['allowed_map', 'partial', 'rejected', 'rejected_map']);
|
||||
assert.strictEqual(eventFieldsForRelease(EVENT_FORMS[0x1F], 18).hidden.length, 0);
|
||||
// a field without a known release is never hidden
|
||||
r = eventFieldsForRelease(EVENT_FORMS[0x12], 4);
|
||||
assert.strictEqual(r.hidden.length, 0);
|
||||
assert.strictEqual(r.fields.length, EVENT_FORMS[0x12].fields.length);
|
||||
});
|
||||
|
||||
test('the single-envelope budget is enforced by the size helper', () => {
|
||||
// inner = 8 header bytes + the event data; D6 81 <len> + inner must stay
|
||||
// within the 1-byte APDU Lc, so the inner data is capped at 252 bytes
|
||||
assert.strictEqual(EVENT_INNER_MAX, 252);
|
||||
assert.strictEqual(eventEnvelopeSize(''), 8);
|
||||
assert.strictEqual(eventEnvelopeSize('AABB'), 10);
|
||||
assert.strictEqual(eventEnvelopeSize('AB'.repeat(244)), 252);
|
||||
assert.ok(eventFitsOneEnvelope('AB'.repeat(244)));
|
||||
assert.ok(!eventFitsOneEnvelope('AB'.repeat(245)));
|
||||
assert.ok(eventFitsOneEnvelope(null));
|
||||
});
|
||||
|
||||
test('the event form previews the bytes the send would deliver', () => {
|
||||
// the preview builds from the visible fields - the same bytes the send
|
||||
// uses, so a raw-envelope test step can be authored by copying it
|
||||
const els = {
|
||||
'ev-f-status': { value: '0' },
|
||||
'ev-f-mcc': { value: '250' },
|
||||
'ev-f-mnc': { value: '01' },
|
||||
'ev-f-lac': { value: '00FF' },
|
||||
'ev-f-cell': { value: '0001' },
|
||||
'event-send-preview': { value: '' },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis._eventFormType = 0x03;
|
||||
pysimEventPreview();
|
||||
assert.strictEqual(els['event-send-preview'].value, '9B0100930752F01000FF0001');
|
||||
// a hidden field is left out of the build
|
||||
els['ev-f-mcc'].closest = () => ({ style: { display: 'none' } });
|
||||
pysimEventPreview();
|
||||
assert.strictEqual(els['event-send-preview'].value, '9B0100');
|
||||
// an event without a form leaves the preview empty
|
||||
globalThis._eventFormType = 0x99;
|
||||
pysimEventPreview();
|
||||
assert.strictEqual(els['event-send-preview'].value, '');
|
||||
});
|
||||
|
||||
test('a name-keyword event points at the right form', () => {
|
||||
// the server's name keywords (used by the applet test scripts) resolve to
|
||||
// their code, so the editor shows the right event and prefills its fields
|
||||
// instead of falling back to event 03
|
||||
const cfg = EVENT_FORMS[eventCode('access_tech')];
|
||||
assert.ok(cfg, 'the access-tech form');
|
||||
assert.deepStrictEqual(cfg.fields.map(f => f.id), ['tech']);
|
||||
assert.strictEqual(cfg.build({ tech: 8 }), 'BF0108');
|
||||
const dc = EVENT_FORMS[eventCode('data_connection')];
|
||||
assert.ok(dc, 'the data-connection form');
|
||||
assert.ok(dc.fields.some(f => f.id === 'status') && dc.fields.some(f => f.id === 'type'));
|
||||
});
|
||||
|
||||
test('the shared event vectors match the PWA builders', () => {
|
||||
// the same fixture is asserted against the Python builders by
|
||||
// tests/test_events.py - a change on either side fails the other suite
|
||||
// until the fixture and both implementations agree
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, 'event_vectors.json'), 'utf8'));
|
||||
assert.ok(vectors.events.length && vectors.scts.length, 'the fixture must not be empty');
|
||||
// the event name aliases (the server's keywords) resolve to the codes
|
||||
assert.ok(vectors.aliases, 'the fixture must carry the aliases');
|
||||
const wantAliases = {};
|
||||
for (const [name, hex] of Object.entries(vectors.aliases)) {
|
||||
wantAliases[name] = parseInt(hex, 16);
|
||||
assert.strictEqual(eventCode(name), parseInt(hex, 16), name);
|
||||
}
|
||||
assert.deepStrictEqual(EVENT_ALIASES, wantAliases);
|
||||
assert.deepStrictEqual(eventCode(0x0B), 0x0B);
|
||||
assert.deepStrictEqual(eventCode('0B'), 0x0B);
|
||||
assert.deepStrictEqual(eventCode('0x1d'), 0x1D);
|
||||
assert.ok(Number.isNaN(eventCode('nonsense')));
|
||||
assert.ok(Number.isNaN(eventCode('')));
|
||||
for (const v of vectors.events) {
|
||||
const build = EVENT_FORMS[parseInt(v.event, 16)].build;
|
||||
assert.strictEqual(build(v.fields).toUpperCase(), v.hex,
|
||||
'event 0x' + v.event + ' ' + JSON.stringify(v.fields));
|
||||
}
|
||||
for (const v of vectors.scts) {
|
||||
const d = {
|
||||
getFullYear: () => v.year, getMonth: () => v.month - 1,
|
||||
getDate: () => v.day, getHours: () => v.hour,
|
||||
getMinutes: () => v.min, getSeconds: () => v.sec,
|
||||
getTimezoneOffset: () => -v.tz_east_min,
|
||||
};
|
||||
assert.strictEqual(eventDateTimeTlv(d).toUpperCase(), '2607' + v.hex,
|
||||
'scts ' + JSON.stringify(v));
|
||||
}
|
||||
});
|
||||
|
||||
test('every event form renders - numeric option values are stringified (v3.23.4)', () => {
|
||||
// esc() is string-only and most EVENT_FORMS option values are numbers
|
||||
// (e.g. {v:0,l:'Normal service'} and the ACCESSTECH list): rendering the
|
||||
// form with esc(o.v) crashed with 'str.replace is not a function' for the
|
||||
// Phone tab and the test-script step editor alike (v3.22.0 regression).
|
||||
globalThis.t = s => s;
|
||||
const bad = [];
|
||||
for (const [ev, cfg] of Object.entries(EVENT_FORMS)) {
|
||||
if (!cfg) continue; // the unimplemented events carry a null form
|
||||
for (const withOnchange of [true, false]) {
|
||||
try {
|
||||
eventFormFieldsHtml(cfg, cfg.fields, {}, 'x-', withOnchange);
|
||||
} catch (e) {
|
||||
bad.push(ev + (withOnchange ? '/phone' : '/step') + ': ' + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(bad, [], 'every event form must render');
|
||||
// a numeric option value lands as its string form and prefills selected
|
||||
const cfg = EVENT_FORMS[0x00];
|
||||
const sel = cfg.fields.find(f => f.type === 'select' && f.opts.some(o => typeof o.v === 'number'));
|
||||
assert.ok(sel, 'a numeric-option select must exist');
|
||||
const num = sel.opts.find(o => typeof o.v === 'number');
|
||||
const out = eventFormFieldsHtml(cfg, cfg.fields, {[sel.id]: num.v}, 'x-', true);
|
||||
assert.ok(out.includes('value="' + String(num.v) + '"'), out);
|
||||
assert.ok(out.includes('value="' + String(num.v) + '" selected'), out);
|
||||
delete globalThis.t;
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"_note": "Shared event-data vectors for the semantic test-script `event` action. Generated from the PWA's EVENT_FORMS builders (frontend/index.html) and asserted by BOTH suites - frontend/tests/event_forms.test.js (against the JS builders) and tests/test_events.py (against pysim_simple_server/events.py) - so the two implementations cannot drift apart. A builder change must update this file, which then fails the other suite until it matches.",
|
||||
"events": [
|
||||
{"event": "03", "fields": {"status": "0", "mcc": "250", "mnc": "01", "lac": "00FF", "cell": "0001"}, "hex": "9B0100930752F01000FF0001"},
|
||||
{"event": "03", "fields": {"status": "1", "mcc": "250"}, "hex": "9B0101"},
|
||||
{"event": "03", "fields": {"status": "0"}, "hex": "9B0100"},
|
||||
{"event": "03", "fields": {"status": "0", "mcc": "310", "mnc": "410", "lac": "ABCD", "cell": "1234"}, "hex": "9B01009307130014ABCD1234"},
|
||||
{"event": "0B", "fields": {"tech": "0"}, "hex": "BF0100"},
|
||||
{"event": "0B", "fields": {"tech": "8"}, "hex": "BF0108"},
|
||||
{"event": "0B", "fields": {"tech": "12"}, "hex": "BF010C"},
|
||||
{"event": "12", "fields": {"reg_type": "0"}, "hex": "F40100BF0100F50102930552F0100000"},
|
||||
{"event": "12", "fields": {"reg_type": "3", "mcc": "262", "mnc": "02", "lac": "00FF", "rac": "05", "access_tech": "3", "cause": "15"}, "hex": "F40103BF0103F5010FF30662F22000FF05"},
|
||||
{"event": "12", "fields": {"reg_type": "9", "mcc": "310", "mnc": "260", "tac": "A1B2C3", "access_tech": "8", "cause": "7", "ext_cause": "22"}, "hex": "F40109BF0108F501077D06130062A1B2C3570116"},
|
||||
{"event": "12", "fields": {"reg_type": "17", "tac": "0001", "access_tech": "10", "cause": "2"}, "hex": "F40111BF010AF501027D0552F0100001"},
|
||||
{"event": "12", "fields": {"reg_type": "9", "access_tech": "8", "cause": "2", "ext_info_type": "1", "ext_info": "00000001"}, "hex": "F40109BF0108F501027D0552F0100001F2050100000001"},
|
||||
{"event": "1D", "fields": {"status": "0", "type": "0", "ti": "00", "loc_status": "0"}, "hex": "9D0100AA01001C01009B0100"},
|
||||
{"event": "1D", "fields": {"status": "1", "type": "2", "cause": "26", "ti": "85", "mcc": "250", "mnc": "01", "lac": "00FF", "cell": "0001", "tech": "8", "loc_status": "1", "apn": "internet", "pdp_type": "3"}, "hex": "9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101C708696E7465726E65740B0103"},
|
||||
{"event": "1D", "fields": {"status": "2", "type": "1", "ti": "", "mcc": "250", "loc_status": "2"}, "hex": "9D0102AA01011C0100130752F010000000019B0102"},
|
||||
{"event": "1D", "fields": {"status": "0", "type": "2", "ti": "AA", "mcc": "262", "mnc": "01", "lac": "0001", "cell": "0002", "tech": "7", "loc_status": "1", "apn": "web.operator.example", "pdp_type": "4"}, "hex": "9D0100AA01021C01AA130762F21000010002BF01079B0101C7147765622E6F70657261746F722E6578616D706C650B0104"}
|
||||
],
|
||||
"aliases": {
|
||||
"location status": "03",
|
||||
"location_status": "03",
|
||||
"location-status": "03",
|
||||
"access technology change": "0B",
|
||||
"access_tech": "0B",
|
||||
"access-technology-change": "0B",
|
||||
"network rejection": "12",
|
||||
"network_rejection": "12",
|
||||
"network-rejection": "12",
|
||||
"data connection status change": "1D",
|
||||
"data_connection": "1D",
|
||||
"data-connection-status-change": "1D"
|
||||
},
|
||||
"scts": [
|
||||
{"year": 2026, "month": 9, "day": 30, "hour": 21, "min": 55, "sec": 0, "tz_east_min": 180, "hex": "62900312550021"},
|
||||
{"year": 2026, "month": 1, "day": 5, "hour": 8, "min": 7, "sec": 9, "tz_east_min": -150, "hex": "62105080709009"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsDecodedMode = false;\n'
|
||||
+ 'var pysimFsEditMode = false;\n'
|
||||
+ 'var pysimFsEditData = null;\n'
|
||||
+ 'var pysimFsSelected = null;\n'
|
||||
+ 'var pysimFsTreeRoot = null;\n';
|
||||
for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit',
|
||||
'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder',
|
||||
'pysimFsSyncContentVisibility']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
eval(code);
|
||||
|
||||
let events = [];
|
||||
let readCalls = 0;
|
||||
let readResult = true;
|
||||
let content, buttons, pills, editables, checkboxes, inputs;
|
||||
|
||||
function fakeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
add: (...c) => c.forEach(x => set.add(x)),
|
||||
remove: (...c) => c.forEach(x => set.delete(x)),
|
||||
toggle: (c, on) => { if (on) set.add(c); else set.delete(c); },
|
||||
contains: c => set.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEl() {
|
||||
return { classList: fakeClassList(), dataset: {} };
|
||||
}
|
||||
|
||||
function setup(opts) {
|
||||
opts = opts || {};
|
||||
events = [];
|
||||
readCalls = 0;
|
||||
readResult = opts.readResult !== false;
|
||||
editables = [{ removeAttribute: () => events.push('readonly-removed') }];
|
||||
checkboxes = [fakeEl()];
|
||||
inputs = [{ addEventListener: () => {} }];
|
||||
content = fakeEl();
|
||||
content.innerHTML = opts.contentHtml || '';
|
||||
content.querySelectorAll = sel => {
|
||||
if (sel === '.pysim-fs-record-cb') return checkboxes;
|
||||
if (sel.indexOf('textarea') === 0) return editables;
|
||||
return inputs;
|
||||
};
|
||||
buttons = {
|
||||
'pysim-fs-edit-btn': fakeEl(),
|
||||
'pysim-fs-save-btn': fakeEl(),
|
||||
'pysim-fs-cancel-btn': fakeEl(),
|
||||
};
|
||||
buttons['pysim-fs-save-btn'].classList.add('hidden');
|
||||
buttons['pysim-fs-cancel-btn'].classList.add('hidden');
|
||||
pills = [fakeEl(), fakeEl()];
|
||||
pills[0].dataset.mode = 'raw';
|
||||
pills[1].dataset.mode = 'dec';
|
||||
|
||||
pysimFsDecodedMode = !!opts.decoded;
|
||||
pysimFsEditMode = false;
|
||||
pysimFsEditData = null;
|
||||
pysimFsSelected = 'EF.IMSI';
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3f00', children: [{ name: 'EF.IMSI', fid: '6f07' }] };
|
||||
globalThis.efFindDecoder = (name, fid) => {
|
||||
const n = (name || '').toUpperCase();
|
||||
const f = (fid || '').toLowerCase();
|
||||
return (n === 'EF.IMSI' || f === '6f07') ? { name: 'EF.IMSI', fid: '6f07' } : null;
|
||||
};
|
||||
|
||||
globalThis.document = {
|
||||
getElementById: id => id === 'pysim-fs-content' ? content : (buttons[id] || null),
|
||||
querySelectorAll: sel => sel === '.pysim-mode-pill' ? pills : [],
|
||||
};
|
||||
globalThis.pysimFsRead = async () => {
|
||||
readCalls++;
|
||||
events.push('read');
|
||||
return readResult;
|
||||
};
|
||||
return {
|
||||
content,
|
||||
btn: id => buttons['pysim-fs-' + id + '-btn'],
|
||||
pill: mode => pills.find(p => p.dataset.mode === mode),
|
||||
reads: () => readCalls,
|
||||
readonlyRemoved: () => events.filter(e => e === 'readonly-removed').length,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
test('Read raw / Read decoded pills switch the view and read the file', () => {
|
||||
const h = setup();
|
||||
pysimFsSetMode('raw', true);
|
||||
assert.strictEqual(pysimFsDecodedMode, false);
|
||||
assert.strictEqual(h.reads(), 0, 'skipRead updates the pills only');
|
||||
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
||||
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
||||
|
||||
pysimFsSetMode('dec');
|
||||
assert.strictEqual(pysimFsDecodedMode, true);
|
||||
assert.strictEqual(h.reads(), 1, 'a pill click reads the selected file');
|
||||
assert.ok(h.pill('dec').classList.contains('bg-blue-600'));
|
||||
assert.ok(!h.pill('raw').classList.contains('bg-blue-600'));
|
||||
});
|
||||
|
||||
test('pill clicks are ignored while editing', () => {
|
||||
const h = setup();
|
||||
pysimFsDecodedMode = true;
|
||||
pysimFsEditMode = true;
|
||||
pysimFsSetMode('raw');
|
||||
assert.strictEqual(pysimFsDecodedMode, true, 'the view mode must not change mid-edit');
|
||||
assert.strictEqual(h.reads(), 0);
|
||||
});
|
||||
|
||||
test('Edit raw keeps a loaded raw view and enters edit mode without re-reading', async () => {
|
||||
const h = setup({ contentHtml: '<textarea readonly>aa</textarea>' });
|
||||
await pysimFsEdit();
|
||||
assert.strictEqual(h.reads(), 0);
|
||||
assert.strictEqual(pysimFsEditMode, true);
|
||||
assert.ok(h.btn('edit').classList.contains('hidden'));
|
||||
assert.ok(!h.btn('save').classList.contains('hidden'));
|
||||
assert.ok(!h.btn('cancel').classList.contains('hidden'));
|
||||
assert.strictEqual(h.readonlyRemoved(), 1);
|
||||
assert.ok(h.pill('raw').classList.contains('opacity-50'), 'pills are dimmed');
|
||||
assert.ok(h.pill('raw').classList.contains('pointer-events-none'));
|
||||
});
|
||||
|
||||
test('Edit raw switches from the decoded view to raw and reads before editing', async () => {
|
||||
const h = setup({ decoded: true });
|
||||
await pysimFsEdit();
|
||||
assert.strictEqual(pysimFsDecodedMode, false);
|
||||
assert.strictEqual(h.reads(), 1);
|
||||
assert.deepStrictEqual(h.events, ['read', 'readonly-removed'],
|
||||
'the raw view must be rendered before the readonly attributes are stripped');
|
||||
assert.ok(h.pill('raw').classList.contains('bg-blue-600'));
|
||||
assert.strictEqual(pysimFsEditMode, true);
|
||||
});
|
||||
|
||||
test('Edit raw reads a fresh selection and aborts if the read fails', async () => {
|
||||
const h = setup({ readResult: false });
|
||||
await pysimFsEdit();
|
||||
assert.strictEqual(h.reads(), 1);
|
||||
assert.strictEqual(pysimFsEditMode, false);
|
||||
assert.ok(h.btn('save').classList.contains('hidden'));
|
||||
assert.ok(!h.pill('raw').classList.contains('opacity-50'));
|
||||
});
|
||||
|
||||
test('Cancel restores the view and re-enables the pills', async () => {
|
||||
const h = setup({ contentHtml: '<textarea readonly>aa</textarea>' });
|
||||
await pysimFsEdit();
|
||||
pysimFsCancel();
|
||||
assert.strictEqual(pysimFsEditMode, false);
|
||||
assert.strictEqual(h.content.innerHTML, '<textarea readonly>aa</textarea>');
|
||||
assert.ok(!h.pill('raw').classList.contains('opacity-50'));
|
||||
assert.ok(!h.pill('raw').classList.contains('pointer-events-none'));
|
||||
assert.ok(!h.btn('edit').classList.contains('hidden'));
|
||||
assert.ok(h.btn('save').classList.contains('hidden'));
|
||||
assert.ok(h.btn('cancel').classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('Reset edit re-enables the pills', () => {
|
||||
const h = setup();
|
||||
pysimFsEditMode = true;
|
||||
pysimFsPillsEnabled(false);
|
||||
pysimFsResetEdit();
|
||||
assert.strictEqual(pysimFsEditMode, false);
|
||||
assert.ok(!h.pill('dec').classList.contains('opacity-50'));
|
||||
});
|
||||
|
||||
test('Read decoded is ignored when the file has no decoder', () => {
|
||||
const h = setup();
|
||||
globalThis.efFindDecoder = () => null;
|
||||
pysimFsSetMode('dec');
|
||||
assert.strictEqual(pysimFsDecodedMode, false);
|
||||
assert.strictEqual(h.reads(), 0);
|
||||
assert.ok(!h.pill('dec').classList.contains('bg-blue-600'));
|
||||
});
|
||||
|
||||
test('pysimFsHasDecoder resolves the selected file by name or FID', () => {
|
||||
setup();
|
||||
assert.strictEqual(pysimFsHasDecoder(), true, 'EF.IMSI is in the tree');
|
||||
// alias name: the decoder is matched by the FID fallback
|
||||
pysimFsTreeRoot = { name: 'MF', children: [{ name: 'EF.ALIAS', fid: '6f07' }] };
|
||||
pysimFsSelected = 'EF.ALIAS';
|
||||
assert.strictEqual(pysimFsHasDecoder(), true);
|
||||
// unknown file -> no decoder
|
||||
globalThis.efFindDecoder = () => null;
|
||||
assert.strictEqual(pysimFsHasDecoder(), false);
|
||||
// nothing selected -> no decoder
|
||||
pysimFsSelected = null;
|
||||
assert.strictEqual(pysimFsHasDecoder(), false);
|
||||
});
|
||||
|
||||
test('the file manager row exposes the read pills and Edit raw', () => {
|
||||
assert.ok(!html.includes('id="pysim-fs-read-btn"'), 'the redundant Read button is gone');
|
||||
assert.ok(html.includes('data-l10n="Read raw">Read raw<'));
|
||||
assert.ok(html.includes('data-l10n="Read decoded">Read decoded<'));
|
||||
assert.ok(html.includes('data-l10n="Edit raw">Edit raw<'));
|
||||
assert.ok(html.includes('id="pysim-fs-read-raw-btn"') && html.includes('data-needs="card"'));
|
||||
const dec = /<button[^>]*id="pysim-fs-read-dec-btn"[^>]*>/.exec(html);
|
||||
assert.ok(dec && dec[0].includes('data-needs-decoder'), 'the decoded pill is decoder-gated');
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
code += extractFunc(html, 'getParentSel') + '\n';
|
||||
code += extractFunc(html, 'getParentPath') + '\n';
|
||||
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
|
||||
code += 'globalThis.pysimCustomInject = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
let calls = [];
|
||||
let responses = [];
|
||||
let renders = 0;
|
||||
|
||||
function setup() {
|
||||
calls = [];
|
||||
responses = [];
|
||||
renders = 0;
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
|
||||
const r = responses.shift();
|
||||
if (r instanceof Error) throw r;
|
||||
return JSON.parse(JSON.stringify(r));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => { renders++; };
|
||||
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
|
||||
return node;
|
||||
}
|
||||
|
||||
test('tree error payload marks the directory as absent', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0].body.parent_sel, 'MF');
|
||||
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
|
||||
});
|
||||
|
||||
test('error payload without exists is not treated as an empty listing', async () => {
|
||||
const node = setup();
|
||||
responses = [
|
||||
{ success: false, error: 'boom' },
|
||||
{ success: false, error: 'boom' },
|
||||
];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(renders, 1);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a 200 exists:false response marks the directory absent without retrying', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: false }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, false);
|
||||
assert.strictEqual(node.children, null);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a node with loaded children is not fetched again', async () => {
|
||||
const node = setup();
|
||||
node.exists = true;
|
||||
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.strictEqual(node.children.length, 1);
|
||||
assert.strictEqual(calls.length, 0);
|
||||
});
|
||||
|
||||
test('empty successful listing keeps the directory present', async () => {
|
||||
const node = setup();
|
||||
responses = [{ exists: true, children: [] }];
|
||||
await pysimFsLoadChildren(node);
|
||||
assert.strictEqual(node.exists, true);
|
||||
assert.deepStrictEqual(node.children, []);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsFitTree', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
textContent: '',
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
};
|
||||
}
|
||||
|
||||
let els = {};
|
||||
let calls = [];
|
||||
|
||||
function setup(routes) {
|
||||
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
|
||||
calls = [];
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.pysimFetch = async (p, body) => {
|
||||
calls.push({ path: p, body: body || {} });
|
||||
for (const r of routes) {
|
||||
if (r.path !== p) continue;
|
||||
if (r.name && (!body || body.name !== r.name)) continue;
|
||||
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
|
||||
}
|
||||
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
|
||||
};
|
||||
globalThis.pysimFsRenderTree = () => {};
|
||||
}
|
||||
|
||||
function root(children) {
|
||||
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
|
||||
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
|
||||
return pysimFsTreeRoot;
|
||||
}
|
||||
|
||||
const df = (name, fid) => ({ name, fid, isDir: true });
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false });
|
||||
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
|
||||
|
||||
test('probes dirs and files, including custom entries, and reports counts', async () => {
|
||||
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
let sawStop = null;
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
|
||||
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(sawStop, 'Stop');
|
||||
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
|
||||
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /4\/4 files/);
|
||||
assert.match(status, /3 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
|
||||
});
|
||||
|
||||
test('an absent directory is marked and its subtree is never fetched', async () => {
|
||||
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /0 present/);
|
||||
assert.match(status, /2 absent/);
|
||||
});
|
||||
|
||||
test('stop halts the walk and still reports a summary', async () => {
|
||||
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.X']);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.ok(status.startsWith('Stopped —'), status);
|
||||
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||
});
|
||||
|
||||
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
|
||||
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
|
||||
assert.deepStrictEqual(body.parent_path, ['MF']);
|
||||
assert.strictEqual(body.parent_sel, 'MF');
|
||||
assert.ok(!body.allow_probe);
|
||||
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
|
||||
assert.deepStrictEqual(custom.parent_path, ['MF']);
|
||||
assert.strictEqual(custom.allow_probe, true);
|
||||
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
|
||||
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
|
||||
});
|
||||
|
||||
test('children of an absent directory are neither fetched nor selected', async () => {
|
||||
const stale = Object.assign(df('DF.C', '5f03'), {
|
||||
exists: false,
|
||||
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
|
||||
});
|
||||
root([stale, ef('EF.ROOT', '2f01')]);
|
||||
setup([
|
||||
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
|
||||
]);
|
||||
await pysimFsProbeAll();
|
||||
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||
assert.strictEqual(stale.exists, false);
|
||||
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
|
||||
const status = els['pysim-fs-probe-status'].textContent;
|
||||
assert.match(status, /2\/2 files/);
|
||||
assert.match(status, /1 present/);
|
||||
assert.match(status, /1 absent/);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsSelected = null;\n'
|
||||
+ 'var pysimFsDecodedMode = false;\n'
|
||||
+ 'var pysimFsTreeRoot = null;\n';
|
||||
code += extractFunc(html, 'pysimFsSyncContentVisibility') + '\n';
|
||||
code += extractFunc(html, 'pysimFsRead') + '\n';
|
||||
eval(code);
|
||||
|
||||
let content, status, fetchResult, errorShown;
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
toggle: (c, on) => { if (on) classes.add(c); else classes.delete(c); },
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
content = fakeEl();
|
||||
status = fakeEl();
|
||||
globalThis.document = {
|
||||
getElementById: id => id === 'pysim-fs-content' ? content
|
||||
: (id === 'pysim-fs-status' ? status : null),
|
||||
};
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => s;
|
||||
globalThis.pysimFsFindNode = () => ({ name: 'EF.IMSI', fid: '6f07' });
|
||||
globalThis.pysimFsSelectBody = () => ({});
|
||||
globalThis.pysimFsShowError = (el, sw, err) => {
|
||||
errorShown = { sw, err };
|
||||
el.textContent = 'SW: ' + sw + ' — ' + err;
|
||||
};
|
||||
globalThis.pysimFetch = async () => {
|
||||
if (fetchResult instanceof Error) throw fetchResult;
|
||||
return fetchResult;
|
||||
};
|
||||
pysimFsSelected = 'EF.IMSI';
|
||||
pysimFsDecodedMode = false;
|
||||
pysimFsTreeRoot = { name: 'MF' };
|
||||
fetchResult = { success: true, sw: '9000', data: 'AABB' };
|
||||
errorShown = null;
|
||||
}
|
||||
|
||||
test('pysimFsSyncContentVisibility hides an empty pane and shows loaded content', () => {
|
||||
setup();
|
||||
content.innerHTML = '';
|
||||
pysimFsSyncContentVisibility();
|
||||
assert.ok(content.classList.contains('hidden'));
|
||||
content.innerHTML = ' ';
|
||||
pysimFsSyncContentVisibility();
|
||||
assert.ok(content.classList.contains('hidden'), 'whitespace-only content stays hidden');
|
||||
content.innerHTML = '<textarea>x</textarea>';
|
||||
pysimFsSyncContentVisibility();
|
||||
assert.ok(!content.classList.contains('hidden'));
|
||||
});
|
||||
|
||||
test('a successful read fills and shows the content pane', async () => {
|
||||
setup();
|
||||
const ok = await pysimFsRead();
|
||||
assert.strictEqual(ok, true);
|
||||
assert.ok(content.innerHTML.includes('AABB'));
|
||||
assert.ok(!content.classList.contains('hidden'));
|
||||
assert.strictEqual(status.textContent, 'SW: 9000 OK');
|
||||
});
|
||||
|
||||
test('a failed read keeps the content pane hidden', async () => {
|
||||
setup();
|
||||
fetchResult = { success: false, sw: '6982', error: 'Security status not satisfied' };
|
||||
const ok = await pysimFsRead();
|
||||
assert.strictEqual(ok, false);
|
||||
assert.strictEqual(content.innerHTML, '');
|
||||
assert.ok(content.classList.contains('hidden'));
|
||||
assert.deepStrictEqual(errorShown, { sw: '6982', err: 'Security status not satisfied' });
|
||||
});
|
||||
|
||||
test('a thrown read error keeps the content pane hidden', async () => {
|
||||
setup();
|
||||
fetchResult = new Error('boom');
|
||||
const ok = await pysimFsRead();
|
||||
assert.strictEqual(ok, false);
|
||||
assert.ok(content.classList.contains('hidden'));
|
||||
assert.ok(status.textContent.includes('boom'));
|
||||
});
|
||||
|
||||
test('the pane starts hidden and every mutation keeps it in sync', () => {
|
||||
assert.match(html, /id="pysim-fs-content"[^>]*class="[^"]*\bhidden\b/);
|
||||
for (const fn of ['pysimFsClickFile', 'pysimFsRead', 'pysimFsEdit', 'pysimFsCancel']) {
|
||||
assert.ok(extractFunc(html, fn).includes('pysimFsSyncContentVisibility'),
|
||||
fn + ' must sync the content pane visibility');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var pysimFsSort = "fid";\n';
|
||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
|
||||
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
|
||||
|
||||
test('absent directory renders a cross and no expand toggle', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
assert.ok(!out.includes('▶'), out);
|
||||
});
|
||||
|
||||
test('expanded empty directory shows the (empty) placeholder', () => {
|
||||
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('expanded directory with children renders no placeholder', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('EF.IMSI'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
});
|
||||
|
||||
test('collapsed directory hides its children', () => {
|
||||
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(!out.includes('EF.IMSI'), out);
|
||||
});
|
||||
|
||||
test('non-existing directory hides previously loaded children', () => {
|
||||
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
|
||||
const out = pysimFsRenderNode(node, 0);
|
||||
assert.ok(out.includes('✗'), out);
|
||||
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
|
||||
assert.ok(!out.includes('(empty)'), out);
|
||||
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'pysimFsSortChildren'));
|
||||
|
||||
const f = (fid, name) => ({ fid, name, isDir: false });
|
||||
const d = (fid, name) => ({ fid, name, isDir: true });
|
||||
|
||||
test('DFs sort before EFs in both modes', () => {
|
||||
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('FID mode sorts EFs numerically by FID string', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
|
||||
});
|
||||
|
||||
test('name mode sorts case-insensitively', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
|
||||
});
|
||||
|
||||
test('missing name falls back to the FID as the sort key', () => {
|
||||
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
|
||||
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
|
||||
});
|
||||
|
||||
test('custom entries use their isDir flag for the DF priority', () => {
|
||||
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
|
||||
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
|
||||
});
|
||||
|
||||
test('equal keys keep a deterministic tie-break by the other field', () => {
|
||||
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
|
||||
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
|
||||
pysimFsSortChildren(children, 'fid');
|
||||
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
|
||||
});
|
||||
+607
-3
@@ -4,9 +4,613 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
const opens = (html.match(/<div\b/g) || []).length;
|
||||
const closes = (html.match(/<\/div>/g) || []).length;
|
||||
// The help pages are HTML too: the same structural walk validates them.
|
||||
const helpPages = ['help.html', 'help-ru.html'].map(f => ({
|
||||
name: f, text: fs.readFileSync(path.join(__dirname, '..', f), 'utf8'),
|
||||
}));
|
||||
|
||||
test('HTML <div> tags are balanced', () => {
|
||||
// The Russian dictionary must cover every user-visible key: the t('...')
|
||||
// literals in the script and every data-l10n / data-l10n-title attribute.
|
||||
// A missing key silently falls back to English - that is how the suite
|
||||
// views' Run/Move/Copy buttons stayed English until v3.22.5.
|
||||
test('every t() key and data-l10n attribute has a Russian entry', () => {
|
||||
const dictMatch = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/);
|
||||
assert.ok(dictMatch, 'LANG_RU not found');
|
||||
const norm = k => k
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'");
|
||||
const keys = new Set();
|
||||
for (const m of dictMatch[1].matchAll(/^\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"):/gm)) {
|
||||
keys.add(norm(m[1] !== undefined ? m[1] : m[2]));
|
||||
}
|
||||
const missing = [];
|
||||
for (const m of html.matchAll(/\bt\(\s*'((?:[^'\\]|\\.)*)'\s*\)/g)) {
|
||||
if (!keys.has(norm(m[1]))) missing.push('t(): ' + m[1]);
|
||||
}
|
||||
for (const m of html.matchAll(/data-l10n(?:-title)?="([^"]+)"/g)) {
|
||||
if (!keys.has(norm(m[1]))) missing.push('data-l10n: ' + m[1]);
|
||||
}
|
||||
assert.deepStrictEqual(missing, [], 'missing Russian entries: ' + missing.join(' | '));
|
||||
});
|
||||
|
||||
// Tags that never take an end tag.
|
||||
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
||||
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
||||
// End tags HTML allows to be omitted: a close tag may legally skip these.
|
||||
const OPTIONAL_END = new Set(['p', 'li', 'dt', 'dd', 'option', 'optgroup',
|
||||
'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'rt', 'rp', 'colgroup', 'caption']);
|
||||
// Start tags that implicitly close the listed open element (the subset used here).
|
||||
const IMPLIED_END = {
|
||||
li: ['li'], dt: ['dt', 'dd'], dd: ['dt', 'dd'], p: ['p'],
|
||||
option: ['option'], optgroup: ['optgroup'],
|
||||
tr: ['tr'], td: ['td', 'th'], th: ['td', 'th'],
|
||||
thead: ['thead', 'tbody', 'tfoot'], tbody: ['thead', 'tbody', 'tfoot'],
|
||||
tfoot: ['thead', 'tbody', 'tfoot'],
|
||||
};
|
||||
|
||||
// The markup without comments and embedded script/style bodies: only this
|
||||
// participates in tag nesting. JavaScript template strings may hold
|
||||
// unbalanced <div> fragments, which is exactly how a missing </div> stayed
|
||||
// invisible to the old whole-file count (the Simulator panels were nested
|
||||
// from v3.5.11 to v3.6.0 while the total stayed balanced).
|
||||
function htmlOnly(src) {
|
||||
return src.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
}
|
||||
|
||||
// Walk the tag stream and report cross-nesting, stray end tags and unclosed
|
||||
// elements. Run on every HTML page for every change. HTML optional end tags
|
||||
// are honoured so a legitimately omitted </p>/</li> is not an error.
|
||||
function htmlStructureProblems(src) {
|
||||
const markup = htmlOnly(src);
|
||||
const problems = [];
|
||||
const stack = [];
|
||||
const re = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/g;
|
||||
let m;
|
||||
while ((m = re.exec(markup))) {
|
||||
const closing = m[1] === '/';
|
||||
const tag = m[2].toLowerCase();
|
||||
const selfClosing = m[3] === '/';
|
||||
if (VOID_TAGS.has(tag) || selfClosing) continue;
|
||||
if (!closing) {
|
||||
for (const t of (IMPLIED_END[tag] || [])) {
|
||||
while (stack.length && stack[stack.length - 1].tag === t) stack.pop();
|
||||
}
|
||||
stack.push({ tag, at: m.index });
|
||||
continue;
|
||||
}
|
||||
if (!stack.length) { problems.push(`stray </${tag}> at offset ${m.index}`); continue; }
|
||||
if (stack[stack.length - 1].tag === tag) { stack.pop(); continue; }
|
||||
let i = stack.length - 1;
|
||||
while (i >= 0 && stack[i].tag !== tag) i--;
|
||||
if (i < 0) { problems.push(`stray </${tag}> at offset ${m.index}`); continue; }
|
||||
const between = stack.slice(i + 1);
|
||||
if (between.every(e => OPTIONAL_END.has(e.tag))) stack.length = i;
|
||||
else {
|
||||
problems.push(`cross-nesting: </${tag}> at offset ${m.index} closes over ` +
|
||||
between.map(e => `<${e.tag}>`).join(' '));
|
||||
}
|
||||
}
|
||||
for (const e of stack) {
|
||||
if (!OPTIONAL_END.has(e.tag)) problems.push(`unclosed <${e.tag}> at offset ${e.at}`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// Depth of a <div id="..."> in the parsed div stream: sibling panels share
|
||||
// one depth, a nested panel sits one level deeper.
|
||||
function divDepthOf(src, id) {
|
||||
const markup = htmlOnly(src);
|
||||
const re = /<(\/?)div\b[^>]*>/g;
|
||||
let depth = 0, m;
|
||||
while ((m = re.exec(markup))) {
|
||||
if (m[1] === '/') { depth -= 1; continue; }
|
||||
depth += 1;
|
||||
if (m[0].includes('id="' + id + '"')) return depth;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
test('HTML <div> tags are balanced in the markup (scripts excluded)', () => {
|
||||
const markup = htmlOnly(html);
|
||||
const opens = (markup.match(/<div\b/g) || []).length;
|
||||
const closes = (markup.match(/<\/div>/g) || []).length;
|
||||
assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`);
|
||||
});
|
||||
|
||||
test('HTML tag nesting is well-formed on every page', () => {
|
||||
for (const page of [{ name: 'index.html', text: html }, ...helpPages]) {
|
||||
assert.deepStrictEqual(htmlStructureProblems(page.text), [],
|
||||
page.name + ' has structure problems');
|
||||
}
|
||||
});
|
||||
|
||||
test('the Simulator panels are siblings of the tab container, never nested', () => {
|
||||
const ids = ['phone-sub-phone', 'phone-sub-tr', 'phone-sub-bip',
|
||||
'phone-sub-esim', 'phone-sub-test'];
|
||||
const depths = ids.map(id => {
|
||||
const d = divDepthOf(html, id);
|
||||
assert.ok(d > 0, 'panel missing: ' + id);
|
||||
return d;
|
||||
});
|
||||
assert.strictEqual(new Set(depths).size, 1,
|
||||
'Simulator panels are nested at different depths: ' +
|
||||
ids.map((id, i) => id + '=' + depths[i]).join(', '));
|
||||
});
|
||||
|
||||
test('top-level tabs match the rearranged views', () => {
|
||||
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'scp81', 'cards', 'profiler', 'pysim', 'phone']);
|
||||
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
||||
});
|
||||
|
||||
test('cards list shows the SCP81 PSK column with blue/red row buttons', () => {
|
||||
assert.match(html, /data-l10n="SCP81">SCP81</);
|
||||
const fn = /function cardsRender\(\)[\s\S]*?\n\}/.exec(html);
|
||||
assert.ok(fn, 'cardsRender not found');
|
||||
assert.match(fn[0], /cardsEdit\(' \+ i \+ '\)" class="[^"]*bg-blue-600 text-white/);
|
||||
assert.match(fn[0], /cardsRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/);
|
||||
});
|
||||
|
||||
test('the card editor groups its fields into labelled fieldsets', () => {
|
||||
const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]);
|
||||
const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f));
|
||||
const tar = fieldsets.find(f => /data-l10n="TAR"/.test(f));
|
||||
const scp80 = fieldsets.find(f => /data-l10n="SCP80 \(GSM 03\.48, ETSI TS 102 225\)"/.test(f));
|
||||
const scp81 = fieldsets.find(f => /data-l10n="SCP81 \(HTTP OTA\)"/.test(f));
|
||||
assert.ok(pinAdm, 'PIN/ADM fieldset not found');
|
||||
assert.ok(tar, 'TAR fieldset not found');
|
||||
assert.ok(scp80, 'SCP80 card fieldset not found');
|
||||
assert.ok(scp81, 'SCP81 card fieldset not found');
|
||||
// the optional card codes (plain text; PIN/PUK are 4-8 decimal digits)
|
||||
for (const id of ['cards-pin1', 'cards-puk1', 'cards-pin2', 'cards-puk2', 'cards-adm']) {
|
||||
assert.ok(pinAdm.includes('id="' + id + '"'), id + ' not in the PIN/ADM fieldset');
|
||||
}
|
||||
// the TAR table: uniform rows rendered into the body, Add TAR below
|
||||
assert.match(tar, /<tbody id="cards-tars">/);
|
||||
assert.match(tar, /onclick="cardsTarAdd\(\)"/);
|
||||
assert.match(tar, />TAR<\/th>/);
|
||||
assert.match(tar, />MSL<\/th>/);
|
||||
assert.match(tar, />Description<\/th>/);
|
||||
// the keyset editor and the PSK pair stay as implemented
|
||||
assert.match(scp80, /id="cards-keysets"/);
|
||||
assert.match(scp80, /onclick="cardsKeysetAdd\(\)"/);
|
||||
// ... and mirror the TAR table: a fixed-layout table with headers
|
||||
assert.match(scp80, /<table class="w-full table-fixed text-sm mb-2">/);
|
||||
assert.match(scp80, /<tbody id="cards-keysets">/);
|
||||
for (const h of ['KIc', 'KID', 'KIc key', 'KID key', 'Counter (hex)']) {
|
||||
assert.ok(scp80.includes('>' + h + '</th>'), h);
|
||||
}
|
||||
assert.match(scp81, /id="cards-psk-id"/);
|
||||
assert.match(scp81, /id="cards-psk-key"/);
|
||||
// the PSK explanation lives inside the SCP81 group, not outside it
|
||||
assert.match(scp81, /data-l10n="SCP81 HTTP OTA: the listener picks the key/);
|
||||
// the preset-wide SPI1/SPI2 inputs are gone: each TAR row carries its MSL
|
||||
assert.ok(!html.includes('id="cards-spi1"'), 'the preset-wide SPI1 input is gone');
|
||||
assert.ok(!html.includes('id="cards-spi2"'), 'the preset-wide SPI2 input is gone');
|
||||
// the name/ICCID fields are in the editor but not inside the fieldsets
|
||||
assert.ok(!fieldsets.some(f => f.includes('id="cards-iccid"')));
|
||||
assert.ok(!fieldsets.some(f => f.includes('id="cards-name"')));
|
||||
});
|
||||
|
||||
test('the ADM input allows the full 4-16 hex digit key', () => {
|
||||
// the server's _verify_adm takes 4-16 hex digits (padded to 8 CHV bytes);
|
||||
// the input must not cap it at the PIN length
|
||||
const m = /<input id="cards-adm"[^>]*>/.exec(html);
|
||||
assert.ok(m, 'the ADM input is missing');
|
||||
const limit = /maxlength="(\d+)"/.exec(m[0]);
|
||||
assert.ok(!limit || parseInt(limit[1], 10) >= 16,
|
||||
'the ADM input must allow 4-16 hex digits (server _verify_adm)');
|
||||
});
|
||||
|
||||
test('the PIN/ADM and TAR fieldsets share one responsive row', () => {
|
||||
const start = html.indexOf('<div class="flex flex-col lg:flex-row gap-3 mb-3">');
|
||||
assert.ok(start >= 0, 'the PIN/ADM + TAR row is missing');
|
||||
const row = html.slice(start, html.indexOf('id="cards-keysets"'));
|
||||
// both fieldsets live in the row: side by side from lg, stacked below
|
||||
assert.match(row, /data-l10n="PIN\/ADM"/);
|
||||
assert.match(row, /data-l10n="TAR"/);
|
||||
assert.match(row, /class="[^"]*rounded p-3 lg:w-56 shrink-0"/);
|
||||
assert.match(row, /class="[^"]*rounded p-3 flex-1 min-w-0"/);
|
||||
// the codes are one-line rows: label left, input right
|
||||
assert.match(row, /class="flex items-center gap-2"[\s\S]*?for="cards-pin1"[\s\S]*?id="cards-pin1"/);
|
||||
assert.match(row, /for="cards-pin1"[^>]*class="w-10 shrink-0/);
|
||||
// the TAR table is fixed-layout so the columns hug their content and the
|
||||
// description absorbs the rest (no even spreading)
|
||||
assert.match(row, /<table class="w-full table-fixed text-sm mb-2">/);
|
||||
assert.match(row, /<th class="w-28 text-left[^"]*" data-l10n="TAR"/);
|
||||
assert.match(row, /<th class="w-20 text-left[^"]*" data-l10n="MSL"/);
|
||||
assert.match(row, /<th class="w-12[^"]*"><\/th>/);
|
||||
// the hint is the short form
|
||||
assert.match(row, /data-l10n="All codes are optional"/);
|
||||
assert.ok(!row.includes('not consumed yet'), 'the long codes hint is gone');
|
||||
});
|
||||
|
||||
test('the Add TAR / Add keyset buttons sit below their lists, green', () => {
|
||||
assert.match(html, /onclick="cardsTarAdd\(\)" class="[^"]*bg-emerald-600[^"]*"/);
|
||||
assert.match(html, /onclick="cardsKeysetAdd\(\)" class="[^"]*bg-emerald-600[^"]*"/);
|
||||
assert.ok(html.indexOf('id="cards-tars"') < html.indexOf('onclick="cardsTarAdd()"'),
|
||||
'Add TAR must follow the TAR list');
|
||||
assert.ok(html.indexOf('id="cards-keysets"') < html.indexOf('onclick="cardsKeysetAdd()"'),
|
||||
'Add keyset must follow the keyset list');
|
||||
assert.ok(html.indexOf('onclick="cardsKeysetAdd()"') > html.indexOf('data-l10n="OTA keysets'),
|
||||
'the keyset label stays above the list');
|
||||
});
|
||||
|
||||
test('the Cards tab splits the preset list and the editor into views', () => {
|
||||
assert.match(html, /id="cards-list-view"/);
|
||||
assert.match(html, /id="cards-editor-view" class="hidden"/);
|
||||
assert.match(html, /id="cards-new-btn"[^>]*onclick="cardsNew\(\)"/);
|
||||
assert.match(html, /id="cards-editor-title"/);
|
||||
const listView = html.slice(html.indexOf('id="cards-list-view"'),
|
||||
html.indexOf('id="cards-editor-view"'));
|
||||
const editorView = html.slice(html.indexOf('id="cards-editor-view"'),
|
||||
html.indexOf('id="event-send-modal"'));
|
||||
// the list view holds the table, the Add preset button and export/import
|
||||
assert.match(listView, /id="cards-tbody"/);
|
||||
assert.match(listView, /id="cards-file"/);
|
||||
assert.match(listView, /id="cards-io"/);
|
||||
assert.match(listView, /cardsExportFile\(\)/);
|
||||
assert.ok(!listView.includes('id="cards-keysets"'), 'the editor must not be in the list view');
|
||||
// the editor view holds the fieldsets and the save/cancel buttons
|
||||
assert.match(editorView, /id="cards-keysets"/);
|
||||
assert.match(editorView, /id="cards-pin1"/);
|
||||
assert.match(editorView, /id="cards-tars"/);
|
||||
assert.match(editorView, /id="cards-add-btn"[^>]*onclick="cardsAdd\(\)"/);
|
||||
assert.match(editorView, /onclick="cardsCancelEdit\(\)"/);
|
||||
assert.ok(!editorView.includes('id="cards-tbody"'), 'the list table must not be in the editor');
|
||||
});
|
||||
|
||||
test('the ADM field sits in the PIN/ADM fieldset of the editor', () => {
|
||||
const fieldsets = [...html.matchAll(/<fieldset[\s\S]*?<\/fieldset>/g)].map(m => m[0]);
|
||||
const pinAdm = fieldsets.find(f => /data-l10n="PIN\/ADM"/.test(f));
|
||||
assert.ok(pinAdm, 'PIN/ADM fieldset not found');
|
||||
assert.match(pinAdm, /id="cards-adm"[^>]*placeholder="ADM"/);
|
||||
// the From card button sits in the editor header, above the code fields
|
||||
assert.ok(html.indexOf('id="cards-iccid-from-card"') < html.indexOf('id="cards-pin1"'),
|
||||
'the From card button must precede the code fields');
|
||||
});
|
||||
|
||||
test('card-dependent profiler buttons show the ICCID and need a readable one', () => {
|
||||
assert.match(html, /id="profiler-from-card-btn" data-needs="card-iccid"/);
|
||||
assert.match(html, /id="snapshot-new-btn" data-needs="card-iccid"/);
|
||||
assert.match(html, /data-needs="card-iccid" onclick="profilerCheck/);
|
||||
// both static buttons carry the dynamic ICCID span
|
||||
assert.match(html, /id="profiler-from-card-btn"[\s\S]*?<span class="profiler-iccid-label"><\/span><\/button>/);
|
||||
assert.match(html, /id="snapshot-new-btn"[\s\S]*?<span class="profiler-iccid-label" data-iccid-sep=": "><\/span><\/button>/);
|
||||
});
|
||||
|
||||
test('network state monitor panel lives next to the network simulation', () => {
|
||||
assert.match(html, /data-l10n="Network state"/);
|
||||
assert.match(html, /id="netstate-badge"/);
|
||||
assert.match(html, /id="netstate-refresh-btn" data-needs="card"/);
|
||||
assert.match(html, /id="netstate-body"/);
|
||||
assert.match(html, /id="netsim-write-fplmn"/);
|
||||
// the monitor is rendered from the cached server state (no file polling)
|
||||
assert.ok(html.includes("netStateFetch()"));
|
||||
});
|
||||
|
||||
test('labelled containers use fieldsets with embedded legends', () => {
|
||||
const legendCls = '<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"';
|
||||
for (const label of ['STK menu', 'STATUS and Polling', 'TERMINAL PROFILE',
|
||||
'Events that the card monitors (SET UP EVENT LIST):', 'Fetched proactive commands:',
|
||||
'HTTP OTA log']) {
|
||||
assert.ok(html.includes(legendCls + ' data-l10n="' + label + '"'), 'no legend for ' + label);
|
||||
}
|
||||
// legends with dynamic state spans put data-l10n on the inner span so
|
||||
// translatePage() does not wipe the sibling
|
||||
assert.ok(html.includes('<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="HTTP OTA listener">HTTP OTA listener</span> <span id="scp81-state"'));
|
||||
assert.ok(html.includes('<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span> <span id="scp81-results-state"'));
|
||||
// the Options block stays a details/summary, with the summary styled as
|
||||
// the embedded label (page-background patch over the border)
|
||||
assert.match(html, /<details id="scp81-opts" class="border border-gray-200 dark:border-slate-700 rounded mb-3">\s*<summary class="w-fit list-inside[^"]*bg-neutral-50 dark:bg-slate-900"/);
|
||||
assert.match(html, /<summary class="w-fit list-inside[^"]*">\s*<span data-l10n="Options \(applied at Start\)">/);
|
||||
});
|
||||
|
||||
test('PLI qualifier tables cover all standard qualifiers', () => {
|
||||
// ESN (07), MEID (0B) and Supported RATs (1A) must at least be named, in
|
||||
// both the TR Config dictionary and the proactive-log short labels.
|
||||
const pli = /const PLI_QUALIFIERS = \[([\s\S]*?)\];/.exec(html);
|
||||
assert.ok(pli, 'PLI_QUALIFIERS not found');
|
||||
for (const code of ['07', '0B', '1A']) {
|
||||
assert.ok(pli[1].includes("{code:'" + code + "'"), 'PLI_QUALIFIERS missing ' + code);
|
||||
}
|
||||
const block = /const CMD_QUALIFIER_SHORT = \{([\s\S]*?)\n\};/.exec(html);
|
||||
assert.ok(block, 'CMD_QUALIFIER_SHORT not found');
|
||||
const short = /'26': \{([^}]*)\}/.exec(block[1]);
|
||||
assert.ok(short, "CMD_QUALIFIER_SHORT['26'] not found");
|
||||
for (const key of ['0x07', '0x0B', '0x1A']) {
|
||||
assert.ok(short[1].includes(key + ':'), 'CMD_QUALIFIER_SHORT 26 missing ' + key);
|
||||
}
|
||||
});
|
||||
|
||||
test('profile rows have a Clone action', () => {
|
||||
assert.match(html, /onclick="profilerClone\(' \+ i \+ '\)"/);
|
||||
assert.match(html, /t\('Clone'\)/);
|
||||
});
|
||||
|
||||
test('the C-APDU and R-APDU parsers are sub-pills of the Parser pill', () => {
|
||||
// The Parser pill is a top-level Remote APDU subtab...
|
||||
assert.match(html, /data-sub="parser" onclick="cApduSwitchSubtab\('parser'\)"/);
|
||||
// ...and both parser panels live inside its container, one after the other.
|
||||
const container = html.indexOf('id="c-apdu-sub-parser"');
|
||||
const parse = html.indexOf('id="c-apdu-sub-parse"');
|
||||
const response = html.indexOf('id="c-apdu-sub-response"');
|
||||
assert.ok(container > 0, 'Parser container missing');
|
||||
assert.ok(parse > container, 'C-APDU panel must be inside the Parser container');
|
||||
assert.ok(response > parse, 'R-APDU panel must follow the C-APDU panel');
|
||||
// Their sub-pill buttons exist and switch through parserSwitchSubtab.
|
||||
assert.match(html, /id="parser-btn-parse" onclick="parserSwitchSubtab\('parse'\)"/);
|
||||
assert.match(html, /id="parser-btn-response" onclick="parserSwitchSubtab\('response'\)"/);
|
||||
});
|
||||
|
||||
test('the document and its inline script are complete', () => {
|
||||
// A truncated index.html (missing </script></body></html>) makes the whole
|
||||
// inline script fail to parse in the browser: every onclick handler then
|
||||
// reports "function is not defined" while the Node tests still pass,
|
||||
// because they extract functions from the text without ever running the
|
||||
// document's script.
|
||||
assert.ok(html.trimEnd().endsWith('</html>'), 'index.html must end with </html>');
|
||||
assert.strictEqual((html.match(/<script\b/g) || []).length,
|
||||
(html.match(/<\/script>/g) || []).length, 'every <script> must be closed');
|
||||
const inline = html.match(/<script>([\s\S]*)<\/script>\s*<\/body>/);
|
||||
assert.ok(inline, 'inline script not found');
|
||||
assert.ok(inline[1].length > 100000, 'inline script looks truncated');
|
||||
new Function(inline[1]); // throws on a syntax error
|
||||
assert.ok(inline[1].includes('function cApduSwitchSubtab'), 'key function missing');
|
||||
});
|
||||
|
||||
test('profiler and simulator are top-level tab contents', () => {
|
||||
assert.ok(html.includes('id="tab-profiler" class="tab-content hidden"'));
|
||||
assert.ok(html.includes('id="tab-phone" class="tab-content hidden"'));
|
||||
});
|
||||
|
||||
test('simulator has Phone / TR Config pills', () => {
|
||||
assert.match(html, /data-phone-sub="phone" onclick="phoneSwitchSubtab\('phone'\)"/);
|
||||
assert.match(html, /data-phone-sub="tr" onclick="phoneSwitchSubtab\('tr'\)"/);
|
||||
assert.ok(html.includes('id="phone-sub-phone"'));
|
||||
assert.ok(html.includes('id="phone-sub-tr"'));
|
||||
});
|
||||
|
||||
test('scan name input starts scanning on Enter', () => {
|
||||
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
|
||||
});
|
||||
|
||||
test('snapshot view has a timing summary block', () => {
|
||||
assert.ok(html.includes('id="snapshot-summary"'));
|
||||
});
|
||||
|
||||
test('header state indicator and profiler custom-files tab', () => {
|
||||
assert.ok(html.includes('id="state-indicator"'));
|
||||
assert.ok(html.includes('id="profiler-list-custom"'));
|
||||
assert.ok(html.includes('data-list-tab="custom"'));
|
||||
assert.ok(!html.includes('data-pysim-sub="custom"'));
|
||||
});
|
||||
|
||||
test('header status indicator has a compact ADM badge', () => {
|
||||
assert.ok(html.includes('id="state-indicator-adm"'));
|
||||
});
|
||||
|
||||
test('file manager has FID / Name sort pills', () => {
|
||||
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
|
||||
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
||||
assert.ok(html.includes('pysim-fs-sort-pill'));
|
||||
});
|
||||
|
||||
test('file manager keeps sort/probe controls above the scrolling tree', () => {
|
||||
// The sort pills and the Probe all files button/status must sit outside
|
||||
// the scrolling tree container so they stay visible while it scrolls.
|
||||
assert.ok(html.indexOf('id="pysim-fs-probe-btn"') < html.indexOf('id="pysim-fs-tree"'));
|
||||
assert.ok(html.indexOf('pysim-fs-sort-pill') < html.indexOf('id="pysim-fs-tree"'));
|
||||
assert.match(html, /style="max-height:65vh"[^>]*>\s*<div id="pysim-fs-tree">/);
|
||||
// the runtime fit caps it to the free viewport space; 65vh stays only as
|
||||
// the no-JS fallback
|
||||
assert.match(html, /function pysimFsFitTree\(/);
|
||||
assert.match(html, /addEventListener\('resize', pysimFsFitTree\)/);
|
||||
});
|
||||
|
||||
test('custom-files init runs after the language init', () => {
|
||||
// pysimCustomLoad/RenderParents call t(): running them before
|
||||
// currentLang is initialized throws a TDZ error and aborts the rest
|
||||
// of the script (all later handlers fail with 'before initialization').
|
||||
assert.ok(html.indexOf('// Init custom files') > html.indexOf("let currentLang = 'en';"));
|
||||
});
|
||||
|
||||
test('custom files form has add/save and cancel controls', () => {
|
||||
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
|
||||
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
|
||||
// canonical path form: root + parent DF + 4-hex FID, no free-form path
|
||||
assert.ok(html.includes('id="pysim-cf-root"'));
|
||||
assert.ok(html.includes('id="pysim-cf-parent"'));
|
||||
assert.ok(html.includes('id="pysim-cf-fid"'));
|
||||
assert.ok(!html.includes('id="pysim-cf-path"'));
|
||||
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
||||
assert.ok(!html.includes('pysimCustomAdd'));
|
||||
});
|
||||
|
||||
test('file manager has a probe-all-files button and status line', () => {
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
|
||||
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
|
||||
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
|
||||
assert.ok(html.includes('id="pysim-fs-probe-status"'));
|
||||
});
|
||||
|
||||
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
|
||||
const detail = html.indexOf('id="pysim-fs-detail"');
|
||||
const info = html.indexOf('id="pysim-fs-info"');
|
||||
const content = html.indexOf('id="pysim-fs-content"');
|
||||
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
|
||||
assert.ok(html.includes('function pysimFsInfoHtml'));
|
||||
assert.ok(html.includes('pysimFsInfoHtml(sel, name, { arr: arrs, path: pysimFsNodePath(node) })'));
|
||||
assert.ok(!html.includes('pysim-fs-filename'));
|
||||
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||
assert.ok(html.includes('pysimFsSelected = name;'));
|
||||
assert.ok(!html.includes('pysimFsSelect()'));
|
||||
});
|
||||
|
||||
test('profile list has a Profile from snapshot button', () => {
|
||||
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
|
||||
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
|
||||
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
|
||||
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
|
||||
});
|
||||
|
||||
test('every help anchor used by the UI exists in help.html', () => {
|
||||
const help = fs.readFileSync(path.join(__dirname, '..', 'help.html'), 'utf8');
|
||||
const ids = new Set([...help.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
||||
const anchors = new Set();
|
||||
// static maps in `xHelpAnchor = {...}` and inline `setHelpAnchor({...})`
|
||||
for (const re of [/HelpAnchor\s*=\s*\{([^}]*)\}/g, /setHelpAnchor\s*\(\s*\{([^}]*)\}/g]) {
|
||||
for (const m of html.matchAll(re)) {
|
||||
for (const v of m[1].matchAll(/'([a-z0-9-]+)'/g)) anchors.add(v[1]);
|
||||
}
|
||||
}
|
||||
for (const m of html.matchAll(/setHelpAnchor\('([^']+)'\)/g)) anchors.add(m[1]);
|
||||
for (const m of html.matchAll(/HelpAnchor\s*=[^;]*\|\|\s*'([a-z0-9-]+)'/g)) anchors.add(m[1]);
|
||||
for (const m of html.matchAll(/setHelpAnchor\s*\([^()]*\|\|\s*'([a-z0-9-]+)'/g)) anchors.add(m[1]);
|
||||
assert.ok(anchors.size >= 15, 'expected at least 15 help anchors, got ' + anchors.size);
|
||||
const missing = [...anchors].filter(a => !ids.has(a));
|
||||
assert.deepStrictEqual(missing, [], 'help.html lacks sections for: ' + missing.join(', '));
|
||||
// the SCP81 tab wires its own anchors and the targets exist
|
||||
assert.ok(ids.has('scp81') && ids.has('scp81-listener') && ids.has('scp81-scripts'));
|
||||
assert.ok(html.includes("'scp81-listener' : 'scp81-scripts'"));
|
||||
assert.ok(html.includes("? 'scp81-scripts' : 'scp81-listener'"));
|
||||
});
|
||||
|
||||
test('simulator has the network-simulation fieldset', () => {
|
||||
assert.ok(html.includes('data-l10n="Network simulation"'));
|
||||
for (const s of ['cold_boot', 'attach_eps', 'attach_2g', 'service_lost',
|
||||
'limited_service', 'roaming_denied', 'churn', 'sms_received',
|
||||
'cb_reconfig', 'authenticate']) {
|
||||
assert.ok(html.includes("netSimRun('" + s + "')"), s);
|
||||
}
|
||||
assert.ok(html.includes('id="netsim-op-search"'));
|
||||
assert.ok(html.includes('id="netsim-log"'));
|
||||
assert.ok(html.includes('id="netsim-status"'));
|
||||
assert.match(html, /id="netsim-mcc" value="001"/);
|
||||
assert.ok(html.includes('data-l10n="Sets the PLMN written to'));
|
||||
// scenario buttons need the card (the server writes real EFs)
|
||||
assert.match(html, /data-needs="card" onclick="netSimRun\('service_lost'\)"/);
|
||||
});
|
||||
|
||||
test('simulator action buttons are green and the TP status sits below them', () => {
|
||||
assert.match(html, /id="tp-send-btn"[^>]*bg-emerald-600/);
|
||||
assert.match(html, /id="pli-status-btn"[^>]*bg-emerald-600/);
|
||||
// #tp-status is outside the button row (the row's </div> comes after
|
||||
// the last button and before the status element)
|
||||
const block = html.slice(html.indexOf('id="tp-send-btn"'), html.indexOf('id="tp-status"'));
|
||||
assert.ok(block.lastIndexOf('</div>') > block.lastIndexOf('</button>'));
|
||||
});
|
||||
|
||||
test('cards and custom files expose only file import/export', () => {
|
||||
assert.ok(!html.includes('data-l10n="Export as JSON"'));
|
||||
assert.ok(!html.includes('data-l10n="Paste & import"'));
|
||||
assert.ok(!html.includes('data-l10n="Import JSON from clipboard"'));
|
||||
for (const fn of ['cardsExportFile', 'cardsImportFile', 'pysimCustomExportFile', 'pysimCustomImportFile']) {
|
||||
assert.ok(html.includes('function ' + fn + '('), fn);
|
||||
}
|
||||
assert.ok(!html.includes('function cardsExport('));
|
||||
assert.ok(!html.includes('function pysimCustomExport('));
|
||||
assert.ok(!html.includes('cardsImportField('));
|
||||
assert.ok(!html.includes('pysimCustomImportField('));
|
||||
// import feedback goes to the transient status line
|
||||
assert.ok(html.includes('function ioStatus('));
|
||||
assert.ok(html.includes("ioStatus('cards-io'"));
|
||||
assert.ok(html.includes("ioStatus('pysim-cf-io'"));
|
||||
});
|
||||
|
||||
test('SCP81 listener exposes its four modes with the matching notes', () => {
|
||||
for (const v of ['tls', 'redirect', 'passthru', 'dump']) {
|
||||
assert.ok(html.includes('value="' + v + '"'), v);
|
||||
}
|
||||
assert.ok(html.includes('id="scp81-redirect-note"'));
|
||||
assert.ok(html.includes('id="scp81-passthru-note"'));
|
||||
// redirect needs the configured target; passthru uses the card's one
|
||||
assert.ok(html.includes("mode === 'redirect' && (!hostVal || !portVal)"));
|
||||
assert.ok(html.includes("el.disabled = (mode === 'passthru')"));
|
||||
});
|
||||
|
||||
test('the network monitor labels its refresh timestamp and has no area line', () => {
|
||||
assert.match(html, /data-l10n="Last refresh">Last refresh<\/span>: <span id="netstate-read"><\/span>/);
|
||||
assert.ok(!/id="netstate-area"/.test(html), 'the area line is gone; LAI/RAI/TAI live in the EF rows');
|
||||
});
|
||||
|
||||
test('every data-l10n attribute resolves in LANG_RU after HTML decoding', () => {
|
||||
// translatePage() looks up el.getAttribute('data-l10n') — the browser
|
||||
// decodes entities in attribute values, but NOT inside the <script>
|
||||
// block where LANG_RU lives. Mirror that lookup exactly: a key written
|
||||
// as " in the markup and " in the dict never matches.
|
||||
const dictMatch = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/);
|
||||
assert.ok(dictMatch, 'LANG_RU literal not found');
|
||||
const dict = eval('({' + dictMatch[1] + '})');
|
||||
const entities = {
|
||||
quot: '"', '#34': '"', amp: '&', '#38': '&', apos: "'", '#39': "'",
|
||||
lt: '<', '#60': '<', gt: '>', '#62': '>', nbsp: ' ',
|
||||
};
|
||||
const decode = s => s.replace(/&([a-z]+|#[0-9]+);/gi,
|
||||
(m, e) => entities[e] !== undefined ? entities[e] : m);
|
||||
const attrs = [...html.matchAll(/data-l10n="([^"]*)"/g)].map(m => decode(m[1]));
|
||||
const titles = [...html.matchAll(/data-l10n-title="([^"]*)"/g)].map(m => decode(m[1]));
|
||||
const missing = [...new Set([...attrs, ...titles].filter(k => !(k in dict)))];
|
||||
assert.deepStrictEqual(missing, [], 'data-l10n keys with no LANG_RU entry:\n' + missing.join('\n'));
|
||||
});
|
||||
|
||||
test('every tab-content lives inside the max-w-7xl page container', () => {
|
||||
// SCP81 and Cards used to sit outside the container (its closing </div>
|
||||
// came before them), so they stretched to the whole window while the
|
||||
// other tabs were capped - v3.6.46.
|
||||
const markup = htmlOnly(html);
|
||||
const re = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*?)>/g;
|
||||
let m;
|
||||
let depth = 0;
|
||||
let containerDepth = null;
|
||||
const seen = new Set();
|
||||
while ((m = re.exec(markup))) {
|
||||
const closing = m[1] === '/';
|
||||
const tag = m[2].toLowerCase();
|
||||
const attrs = m[3] || '';
|
||||
const selfClosing = attrs.trim().endsWith('/');
|
||||
if (VOID_TAGS.has(tag) || selfClosing) continue;
|
||||
if (closing) {
|
||||
if (containerDepth !== null && depth === containerDepth) containerDepth = null;
|
||||
depth--;
|
||||
continue;
|
||||
}
|
||||
if (tag === 'div' && /class="[^"]*max-w-7xl/.test(attrs)) containerDepth = depth + 1;
|
||||
if (tag === 'div' && /class="[^"]*tab-content/.test(attrs)) {
|
||||
const id = (/id="([^"]+)"/.exec(attrs) || [])[1];
|
||||
assert.notStrictEqual(containerDepth, null,
|
||||
id + ' must be inside the max-w-7xl page container');
|
||||
seen.add(id);
|
||||
}
|
||||
depth++;
|
||||
}
|
||||
assert.strictEqual(seen.size, 7, 'expected all seven tabs, saw ' + seen.size);
|
||||
});
|
||||
|
||||
test('the RAM form shows Card preset and Operation in one row', () => {
|
||||
const row = /<div class="mb-3 flex gap-2 items-end">\s*<div id="ram-preset-cell" class="flex-1">\s*<label[^>]*data-l10n="Card preset"[\s\S]*?id="ram-card-sel"[\s\S]*?data-l10n="Operation"[\s\S]*?id="ram-op"[\s\S]*?data-l10n="Execute"/.exec(html);
|
||||
assert.ok(row, 'Card preset + Operation + Execute must share one row');
|
||||
// the batch mode hides the preset cell and Execute (per-card resolution)
|
||||
assert.match(html, /setHidden\('ram-preset-cell', isBatch\)/);
|
||||
});
|
||||
|
||||
test('the RAM operation modal is wide enough for the step lines', () => {
|
||||
const m = /<div id="ram-op-modal"[\s\S]*?<div class="([^"]*max-w-[^"]*)"/.exec(html);
|
||||
assert.ok(m, 'RAM op modal not found');
|
||||
assert.ok(m[1].includes('max-w-3xl'), 'the modal was widened to fit the step lines: ' + m[1]);
|
||||
});
|
||||
|
||||
test('the toolkit parameters grid is three columns with the access row last', () => {
|
||||
// 12 narrow fields fit in 4 rows (the MSL select makes a 13th cell, so
|
||||
// the file-access group spans the full width as its own last row) - v3.6.50
|
||||
const grid = /<div class="grid grid-cols-3 gap-x-3 gap-y-2 text-sm">[\s\S]*?id="rc-tk-access-row"/.exec(html);
|
||||
assert.ok(grid, 'toolkit grid must be 3 columns with the access row in it');
|
||||
const row = /<div id="rc-tk-access-row" class="col-span-3 flex items-center gap-4">([\s\S]*?)<\/div>\s*<\/div>/.exec(html);
|
||||
assert.ok(row, 'the access row spans the grid width');
|
||||
for (const id of ['rc-tk-fsaccess-row', 'rc-tk-adfaccess-row', 'rc-tk-adfaid-row']) {
|
||||
assert.ok(row[1].includes(id), id + ' must sit in the access row');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ function extractFunc(src, name) {
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const FNS = ['_hotaHex', '_hotaAsciiHex', '_hotaBerLen', '_hotaTlv',
|
||||
const FNS = ['_hotaHex', '_hotaAsciiHex', '_hotaBerLen', '_hotaPad', '_hotaTlv',
|
||||
'hotaTlv', 'hotaBuildConn', 'hotaBuildSec', 'hotaBuildRetry',
|
||||
'hotaBuildHttpPost', 'hotaBuildTrigger', 'hotaBuildStore', 'hotaBuild'];
|
||||
let code = '';
|
||||
@@ -68,7 +68,10 @@ test('hotaBuildConn produces a full 84 TLV', () => {
|
||||
]),
|
||||
'840B0103014001020281820500');
|
||||
assert.strictEqual(hotaBuildConn([{ tag: '02', value: '8182' }, { tag: '', value: 'FF' }]), '840402028182');
|
||||
assert.strictEqual(hotaBuildConn([]), '8400');
|
||||
// a half byte is padded, never turned into a fractional BER length
|
||||
assert.strictEqual(hotaBuildConn([{ tag: '35', value: '3' }]), '8403350103');
|
||||
// '84' is 1-n per Table 4-5: an empty container is left out entirely
|
||||
assert.strictEqual(hotaBuildConn([]), '');
|
||||
});
|
||||
|
||||
test('hotaBuildSec produces a full 85 TLV per Table 4-6', () => {
|
||||
@@ -81,21 +84,36 @@ test('hotaBuildSec produces a full 85 TLV per Table 4-6', () => {
|
||||
});
|
||||
|
||||
test('hotaBuildRetry uses TS 102 223 timer TLV (25 03) and wraps in 86', () => {
|
||||
// the delay fields use the TP-SCTS semi-octet order (TS 23.040 9.1.2.3):
|
||||
// within an octet the low nibble holds the most significant digit, so
|
||||
// 1 min = '10', 20 s = '02', 10 min = '01', 1 h 2 min 3 s = '10 20 30'
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: 'B000', delayH: 1, delayM: 2, delayS: 3, reportFailure: '' }),
|
||||
'8607B0002503010203');
|
||||
'8607B0002503102030');
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: 'b0 00', delayH: '1', delayM: '2', delayS: '3', reportFailure: '0A080102030405060708' }),
|
||||
'8611B00025030102030A080102030405060708');
|
||||
'8611B00025031020300A080102030405060708');
|
||||
// counter is 2 bytes mandatory: short input is left-padded, odd report dropped
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: '0000', delayH: 0, delayM: 0, delayS: 0, reportFailure: '0A0' }),
|
||||
'860700002503000000');
|
||||
// round-trip of the card's own 1-minute timer value 25 03 00 10 00
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: '0001', delayH: 0, delayM: 1, delayS: 0, reportFailure: '' }),
|
||||
'860700012503001000');
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: 'B0', delayH: 0, delayM: 0, delayS: 20, reportFailure: '' }),
|
||||
'860700B02503000002');
|
||||
assert.strictEqual(
|
||||
hotaBuildRetry({ counter: '0', delayH: 0, delayM: 10, delayS: 0, reportFailure: '' }),
|
||||
'860700002503000100');
|
||||
});
|
||||
|
||||
test('hotaBuildHttpPost wraps 8A/8B/8C in a full 89 TLV', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildHttpPost({ host: '', agent: '', uri: '' }),
|
||||
'89068A008B008C00');
|
||||
test('hotaBuildHttpPost wraps non-empty 8A/8B/8C in a full 89 TLV', () => {
|
||||
// '8A'..'8C' are 1-n per Tables 4-8/9/10: empty parameters are omitted
|
||||
assert.strictEqual(hotaBuildHttpPost({ host: '', agent: '', uri: '' }), '');
|
||||
assert.strictEqual(hotaBuildHttpPost({ host: 'megafon.ru' }), '890C8A0A6D656761666F6E2E7275');
|
||||
assert.strictEqual(hotaBuildHttpPost({ agent: 'v1.0' }), '89068B0476312E30');
|
||||
assert.strictEqual(
|
||||
hotaBuildHttpPost({ host: 'megafon.ru', agent: 'v1.0', uri: '/sd' }),
|
||||
'89178A0A6D656761666F6E2E72758B0476312E308C032F7364');
|
||||
@@ -104,11 +122,13 @@ test('hotaBuildHttpPost wraps 8A/8B/8C in a full 89 TLV', () => {
|
||||
test('hotaBuildTrigger wraps 81 > 83 > (84/85/86/89)', () => {
|
||||
const conn = '840402028182';
|
||||
const sec = '850400020101';
|
||||
const retry = '8607B0002503010203';
|
||||
const httpPost = '89068A008B008C00';
|
||||
const retry = '8607B0002503102030';
|
||||
const httpPost = '890C8A0A6D656761666F6E2E7275';
|
||||
assert.strictEqual(
|
||||
hotaBuildTrigger(conn, sec, retry, httpPost, false),
|
||||
'811F831D8404020281828504000201018607B000250301020389068A008B008C00');
|
||||
'812583238404020281828504000201018607B0002503102030890C8A0A6D656761666F6E2E7275');
|
||||
// '81' is mandatory but 0-n; without parameters it stays an empty container
|
||||
assert.strictEqual(hotaBuildTrigger('', '', '', '', false), '8100');
|
||||
});
|
||||
|
||||
test('hotaBuildTrigger expanded wraps the 81 command in Command Scripting template AA', () => {
|
||||
@@ -127,6 +147,19 @@ test('hotaBuildStore emits STORE DATA TLV-mode APDU (80 E2 90 00)', () => {
|
||||
assert.strictEqual(
|
||||
hotaBuildStore('840402028182', '', '', '', 'A5'),
|
||||
'80E2900008A506840402028182');
|
||||
// nothing to store -> no APDU
|
||||
assert.strictEqual(hotaBuildStore('', '', '', '', '85'), '');
|
||||
// parameters longer than a short APDU are chained: P1.b8=0 for all but
|
||||
// the last block, P2 = block number, BER-TLV coding throughout
|
||||
const big = '84' + _hotaBerLen(140) + 'AA'.repeat(140);
|
||||
const inner = '85' + _hotaBerLen(big.length / 2) + big;
|
||||
const blocks = [];
|
||||
for (let i = 0; i < inner.length; i += 127 * 2) blocks.push(inner.slice(i, i + 127 * 2));
|
||||
assert.strictEqual(blocks.length, 2);
|
||||
assert.strictEqual(
|
||||
hotaBuildStore(big, '', '', '', '85'),
|
||||
'80E210007F' + blocks[0] +
|
||||
'80E29001' + (blocks[1].length / 2).toString(16).padStart(2, '0').toUpperCase() + blocks[1]);
|
||||
});
|
||||
|
||||
test('hotaBuild dispatches on mode', () => {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractConst(src, name) {
|
||||
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
return 'var ' + name + ' = ' + m[1] + ';\n';
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\nvar _pysimCardIccid = null;\nvar _pysimHeaderIccid = undefined;\nvar _pysimHeaderScp80 = undefined;\nvar _pysimHeaderScp81 = undefined;\n';
|
||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
||||
code += extractFunc(html, 'pysimNeedsHint') + '\n';
|
||||
code += extractFunc(html, 'pysimApplyAvailability') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateCardActionLabels') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdateIccidIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicator') + '\n';
|
||||
code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
|
||||
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES') + extractConst(html, 'CARDS_MSL_DEFAULT');
|
||||
code += extractFunc(html, 'cardsTarList') + '\n';
|
||||
code += extractFunc(html, 'cardsScp80Complete') + '\n';
|
||||
code += extractFunc(html, 'cardsScp81Complete') + '\n';
|
||||
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
|
||||
code += extractFunc(html, 'spKeysetStartReady') + '\n';
|
||||
code += extractFunc(html, 'spKeysetGuard') + '\n';
|
||||
code += extractFunc(html, 'ramKeysetGuard') + '\n';
|
||||
code += extractFunc(html, 'spTarKeysetGuard') + '\n';
|
||||
code += extractFunc(html, 'cardsTarEntry') + '\n';
|
||||
code += extractFunc(html, 'cardsRoleTar') + '\n';
|
||||
code += extractFunc(html, 'cardsRoleDefault') + '\n';
|
||||
code += extractFunc(html, 'spPresetIdx') + '\n';
|
||||
code += extractFunc(html, 'ramPresetIdx') + '\n';
|
||||
code += extractFunc(html, 'tarPresetIdx') + '\n';
|
||||
code += extractFunc(html, 'spKeysetCheck') + '\n';
|
||||
code += extractFunc(html, 'spKeysetFor') + '\n';
|
||||
code += extractFunc(html, 'spKeysetList') + '\n';
|
||||
code += extractFunc(html, 'spKeysetKvnOf') + '\n';
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
code += 'globalThis.testRunActive = () => false;\n';
|
||||
eval(code);
|
||||
|
||||
function fakeEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
classes,
|
||||
attrs: {},
|
||||
classList: {
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
getAttribute(k) { return this.attrs[k]; },
|
||||
hasAttribute(k) { return k in this.attrs; },
|
||||
removeAttribute(k) { delete this.attrs[k]; },
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const els = {
|
||||
'state-indicator': fakeEl(),
|
||||
'state-indicator-dot': fakeEl(),
|
||||
'state-indicator-img': fakeEl(),
|
||||
'state-indicator-iccid': fakeEl(),
|
||||
'state-indicator-scp80': fakeEl(),
|
||||
'state-indicator-scp81': fakeEl(),
|
||||
};
|
||||
els['state-indicator-img'].src = '';
|
||||
globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
|
||||
_pysimServerAvailable = null;
|
||||
_pysimCardEquipped = false;
|
||||
_pysimEquipping = false;
|
||||
_pysimCardIccid = null;
|
||||
_pysimHeaderIccid = undefined;
|
||||
_pysimHeaderScp80 = undefined;
|
||||
_pysimHeaderScp81 = undefined;
|
||||
globalThis.cards = [];
|
||||
globalThis.cardsFindByIccid = () => -1;
|
||||
return els;
|
||||
}
|
||||
|
||||
test('unprobed server shows a gray dot and a Connecting title', () => {
|
||||
const els = setup();
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('hidden'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'Connecting...');
|
||||
assert.strictEqual(dot.attrs.title, 'Connecting...');
|
||||
});
|
||||
|
||||
test('unreachable server shows a red dot', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('text-red-500'));
|
||||
assert.ok(!dot.classes.has('text-gray-400'));
|
||||
assert.ok(img.classes.has('hidden'));
|
||||
assert.strictEqual(wrap.attrs.title, 'No server connection');
|
||||
assert.strictEqual(dot.attrs.title, 'No server connection');
|
||||
});
|
||||
|
||||
test('server up without a card shows nosim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
|
||||
assert.ok(dot.classes.has('hidden'));
|
||||
assert.ok(!img.classes.has('hidden'));
|
||||
assert.strictEqual(img.src, 'nosim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped');
|
||||
assert.strictEqual(dot.attrs.title, undefined);
|
||||
});
|
||||
|
||||
test('equipped card shows sim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card equipped');
|
||||
});
|
||||
|
||||
test('equipping shows the animated sim_anim.svg', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = true;
|
||||
_pysimEquipping = true;
|
||||
pysimUpdateStateIndicator();
|
||||
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
|
||||
assert.strictEqual(img.src, 'sim_anim.svg');
|
||||
assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...');
|
||||
});
|
||||
|
||||
test('dot color transitions do not accumulate', () => {
|
||||
const els = setup();
|
||||
_pysimServerAvailable = false;
|
||||
pysimUpdateStateIndicator();
|
||||
_pysimServerAvailable = null;
|
||||
pysimUpdateStateIndicator();
|
||||
const dot = els['state-indicator-dot'];
|
||||
assert.ok(dot.classes.has('text-gray-400'));
|
||||
assert.ok(!dot.classes.has('text-red-500'));
|
||||
});
|
||||
|
||||
test('indicator markup carries the dot and image elements', () => {
|
||||
assert.match(html, /id="state-indicator-dot"/);
|
||||
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
|
||||
});
|
||||
|
||||
test('header prints the equipped card ICCID next to the card image', () => {
|
||||
const els = setup();
|
||||
pysimUpdateIccidIndicator('89701450001700031958');
|
||||
const el = els['state-indicator-iccid'];
|
||||
assert.strictEqual(el.textContent, '89701450001700031958');
|
||||
assert.ok(!el.classes.has('hidden'));
|
||||
// no card session -> hidden again
|
||||
pysimUpdateIccidIndicator(null);
|
||||
assert.strictEqual(el.textContent, '');
|
||||
assert.ok(el.classes.has('hidden'));
|
||||
// markup order: image, ICCID, ADM badge, SCP80, SCP81
|
||||
assert.ok(html.indexOf('id="state-indicator-img"') < html.indexOf('id="state-indicator-iccid"'));
|
||||
assert.ok(html.indexOf('id="state-indicator-iccid"') < html.indexOf('id="state-indicator-adm"'));
|
||||
assert.ok(html.indexOf('id="state-indicator-adm"') < html.indexOf('id="state-indicator-scp80"'));
|
||||
assert.ok(html.indexOf('id="state-indicator-scp80"') < html.indexOf('id="state-indicator-scp81"'));
|
||||
});
|
||||
|
||||
test('SCP80/SCP81 markers follow the matching preset completeness', () => {
|
||||
const els = setup();
|
||||
globalThis.cards = [{ name: 'C', kic: '15', kid: '15',
|
||||
cntr: '0000000001', kicKey: 'AA', kidKey: 'BB', pskIdentity: 'id', pskKey: 'KEY',
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16' },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '16' }] }];
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
_pysimCardIccid = '89701450001700031958';
|
||||
pysimUpdatePresetIndicators();
|
||||
const e80 = els['state-indicator-scp80'];
|
||||
const e81 = els['state-indicator-scp81'];
|
||||
assert.strictEqual(e80.textContent, 'SCP80');
|
||||
assert.strictEqual(e81.textContent, 'SCP81');
|
||||
assert.ok(!e80.classes.has('hidden'));
|
||||
assert.ok(!e81.classes.has('hidden'));
|
||||
assert.strictEqual(e80.attrs.title, 'SCP80 preset complete');
|
||||
assert.strictEqual(e81.attrs.title, 'SCP81 preset complete');
|
||||
// SCP80 incomplete (missing key) -> only the SCP81 marker stays
|
||||
globalThis.cards[0].kicKey = '';
|
||||
pysimUpdatePresetIndicators();
|
||||
assert.ok(e80.classes.has('hidden'));
|
||||
assert.strictEqual(e80.textContent, '');
|
||||
assert.strictEqual(e80.attrs.title, undefined);
|
||||
assert.ok(!e81.classes.has('hidden'));
|
||||
// no matching preset -> both hidden
|
||||
globalThis.cardsFindByIccid = () => -1;
|
||||
pysimUpdatePresetIndicators();
|
||||
assert.ok(e81.classes.has('hidden'));
|
||||
// explicit null ICCID (no card / server lost) hides them
|
||||
globalThis.cardsFindByIccid = () => 0;
|
||||
pysimUpdatePresetIndicators(null);
|
||||
assert.ok(e80.classes.has('hidden'));
|
||||
assert.ok(e81.classes.has('hidden'));
|
||||
});
|
||||
|
||||
test('card-iccid controls need an equipped card with a readable ICCID', () => {
|
||||
const check = (state, iccid) => {
|
||||
const el = fakeEl();
|
||||
el.setAttribute('data-needs', 'card-iccid');
|
||||
globalThis.document = { querySelectorAll: () => [el], getElementById: () => null };
|
||||
_pysimServerAvailable = state !== 'server-down';
|
||||
_pysimCardEquipped = state === 'card';
|
||||
_pysimCardIccid = iccid;
|
||||
pysimApplyAvailability();
|
||||
return el;
|
||||
};
|
||||
// no card -> disabled
|
||||
let el = check('no-card', null);
|
||||
assert.strictEqual(el.disabled, true);
|
||||
assert.strictEqual(el.attrs.title, 'Insert and equip a card');
|
||||
// equipped but unreadable ICCID -> disabled with its own hint
|
||||
el = check('card', null);
|
||||
assert.strictEqual(el.disabled, true);
|
||||
assert.strictEqual(el.attrs.title, 'Card equipped but its ICCID is not readable');
|
||||
// equipped with an ICCID -> enabled
|
||||
el = check('card', '8970119000004600098');
|
||||
assert.strictEqual(el.disabled, false);
|
||||
assert.strictEqual(el.attrs.title, undefined);
|
||||
// server down -> disabled
|
||||
el = check('server-down', '8970119000004600098');
|
||||
assert.strictEqual(el.disabled, true);
|
||||
});
|
||||
|
||||
test('Read decoded needs a client-side decoder for the selected file', () => {
|
||||
const run = (hasDecoder, cardEquipped) => {
|
||||
const el = fakeEl();
|
||||
el.setAttribute('data-needs', 'card');
|
||||
el.setAttribute('data-needs-decoder', '');
|
||||
globalThis.document = { querySelectorAll: () => [el], getElementById: () => null };
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = cardEquipped;
|
||||
globalThis.pysimFsHasDecoder = () => hasDecoder;
|
||||
pysimApplyAvailability();
|
||||
return el;
|
||||
};
|
||||
// decoder present, card equipped -> enabled, no tooltip
|
||||
let el = run(true, true);
|
||||
assert.strictEqual(el.disabled, false);
|
||||
assert.strictEqual(el.attrs.title, undefined);
|
||||
// no decoder -> disabled with its own hint
|
||||
el = run(false, true);
|
||||
assert.strictEqual(el.disabled, true);
|
||||
assert.strictEqual(el.attrs.title, 'No decoder for this file');
|
||||
// card missing and no decoder -> the card hint wins
|
||||
el = run(false, false);
|
||||
assert.strictEqual(el.disabled, true);
|
||||
assert.strictEqual(el.attrs.title, 'Insert and equip a card');
|
||||
});
|
||||
|
||||
test('indicator image stays within the 32px header row budget', () => {
|
||||
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
|
||||
assert.ok(m, 'inline image size not found');
|
||||
assert.strictEqual(m[1], m[2]);
|
||||
const size = Number(m[1]);
|
||||
assert.ok(size >= 24 && size <= 32, 'size ' + size + 'px would change the header height');
|
||||
});
|
||||
|
||||
test('header badges are bordered chips with even spacing', () => {
|
||||
for (const id of ['state-indicator-adm', 'state-indicator-scp80', 'state-indicator-scp81']) {
|
||||
const m = new RegExp('id="' + id + '"[^>]*class="([^"]*)"').exec(html);
|
||||
assert.ok(m, id + ' markup not found');
|
||||
assert.match(m[1], /\bborder\b/);
|
||||
assert.match(m[1], /\brounded\b/);
|
||||
}
|
||||
assert.match(html, /id="state-indicator-scp80"[^>]*class="[^"]*ml-1/);
|
||||
assert.match(html, /id="state-indicator-scp81"[^>]*class="[^"]*ml-1/);
|
||||
});
|
||||
|
||||
test('card action labels carry the equipped ICCID', () => {
|
||||
setup();
|
||||
const plain = fakeEl();
|
||||
const colon = fakeEl();
|
||||
colon.attrs['data-iccid-sep'] = ': ';
|
||||
globalThis.document.querySelectorAll = sel => sel === '.profiler-iccid-label' ? [plain, colon] : [];
|
||||
_pysimCardIccid = '89701450001700031958';
|
||||
pysimUpdateCardActionLabels();
|
||||
assert.strictEqual(plain.textContent, ' 89701450001700031958');
|
||||
assert.strictEqual(colon.textContent, ': 89701450001700031958');
|
||||
_pysimCardIccid = null;
|
||||
pysimUpdateCardActionLabels();
|
||||
assert.strictEqual(plain.textContent, '');
|
||||
assert.strictEqual(colon.textContent, '');
|
||||
});
|
||||
|
||||
test('the start buttons wait for a preset and a keyset defined in it', () => {
|
||||
const run = (presetIdx, kic, kid) => {
|
||||
const el = fakeEl();
|
||||
el.setAttribute('data-needs', 'card');
|
||||
el.setAttribute('data-needs-keyset', 'sp');
|
||||
const els = {
|
||||
'sp-card-sel': { value: presetIdx },
|
||||
'sp-kic-hex': { value: kic },
|
||||
'sp-kid-hex': { value: kid },
|
||||
};
|
||||
globalThis.document = { querySelectorAll: () => [el], getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'EP', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
|
||||
_pysimServerAvailable = true;
|
||||
_pysimCardEquipped = true;
|
||||
pysimApplyAvailability();
|
||||
return el;
|
||||
};
|
||||
// no preset selected -> disabled with the hint
|
||||
let el = run('', '15', '15');
|
||||
assert.strictEqual(el.disabled, true);
|
||||
assert.strictEqual(el.attrs.title, 'Select a card preset and its keyset');
|
||||
// a keyset number the preset does not define -> disabled
|
||||
el = run('0', '35', '35');
|
||||
assert.strictEqual(el.disabled, true);
|
||||
// a keyset of the preset -> enabled, no tooltip
|
||||
el = run('0', '15', '15');
|
||||
assert.strictEqual(el.disabled, false);
|
||||
assert.strictEqual(el.attrs.title, undefined);
|
||||
});
|
||||
|
||||
test('the SCP80/RAM/TAR start buttons carry the keyset gate', () => {
|
||||
const sp = /<button[^>]*id="sp-send-btn"[^>]*>/.exec(html)[0];
|
||||
assert.match(sp, /data-needs-keyset="sp"/);
|
||||
const ram = /<button[^>]*onclick="ramExecute\(\)"[^>]*>/.exec(html)[0];
|
||||
assert.match(ram, /data-needs-keyset="ram"/);
|
||||
const tar = /<button[^>]*onclick="tarExecute\(\)"[^>]*>/.exec(html)[0];
|
||||
assert.match(tar, /data-needs-keyset="tar"/);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'spCounterTracked', 'spPresetIdx', 'ramPresetIdx',
|
||||
'tarPresetIdx', 'spKeysetSync', 'ramRender', 'esc', 'escHtml',
|
||||
'cardsTarList', 'cardsTarEntry', 'cardsRoleTar',
|
||||
'spTarChanged', 'spKeysetApply', 'updateSpKic', 'updateSpKid']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
code += 'globalThis.pysimApplyAvailability = () => {};\n';
|
||||
eval(code);
|
||||
|
||||
const PRESET = {
|
||||
name: 'Card A',
|
||||
keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' },
|
||||
{ kic: '29', kid: '29', kicKey: 'CC', kidKey: 'DD', cntr: '0000000005' },
|
||||
],
|
||||
};
|
||||
|
||||
test('spKeysetKvnOf reads the keyset number from the high nibble', () => {
|
||||
assert.strictEqual(spKeysetKvnOf('15'), 1);
|
||||
assert.strictEqual(spKeysetKvnOf('29'), 2);
|
||||
assert.strictEqual(spKeysetKvnOf('3A'), 3);
|
||||
assert.strictEqual(spKeysetKvnOf('05'), 0);
|
||||
assert.strictEqual(spKeysetKvnOf('ff'), 15);
|
||||
// not a byte: no keyset number
|
||||
assert.strictEqual(spKeysetKvnOf(''), null);
|
||||
assert.strictEqual(spKeysetKvnOf('5'), null);
|
||||
assert.strictEqual(spKeysetKvnOf('ZZ'), null);
|
||||
assert.strictEqual(spKeysetKvnOf(null), null);
|
||||
});
|
||||
|
||||
test('spKeysetList converts a v3.8.0 flat preset into one keyset', () => {
|
||||
assert.deepStrictEqual(spKeysetList({ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
|
||||
cntr: '0000000001' }), [{ kic: '15', kid: '15', kicKey: 'A', kidKey: 'B',
|
||||
cntr: '0000000001' }]);
|
||||
assert.deepStrictEqual(spKeysetList(PRESET).length, 2);
|
||||
assert.deepStrictEqual(spKeysetList(null), []);
|
||||
assert.deepStrictEqual(spKeysetList({}), []);
|
||||
});
|
||||
|
||||
test('spKeysetFor finds a keyset by number', () => {
|
||||
assert.strictEqual(spKeysetFor(PRESET, 1).cntr, '0000000001');
|
||||
assert.strictEqual(spKeysetFor(PRESET, 2).cntr, '0000000005');
|
||||
assert.strictEqual(spKeysetFor(PRESET, 3), null);
|
||||
assert.strictEqual(spKeysetFor(null, 1), null);
|
||||
});
|
||||
|
||||
test('spKeysetCheck enforces the keyset-number rules', () => {
|
||||
// a defined keyset number passes
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '15', '15'), '');
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '29', '29'), '');
|
||||
// '00'/'00' means no security: no keyset needed (TS 102 225 A.2)
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '00', '00'), '');
|
||||
// ... and one zero byte with a defined version passes too
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '15', '00'), '');
|
||||
// KIc/KID versions must agree when both are non-zero (A.2)
|
||||
assert.match(spKeysetCheck(PRESET, '15', '25'), /same keyset number/);
|
||||
// the keyset number must be defined in the preset
|
||||
assert.match(spKeysetCheck(PRESET, '35', '35'), /3 .*not defined/);
|
||||
// one zero byte: the other byte's version applies (keyset 2 is defined)
|
||||
assert.strictEqual(spKeysetCheck(PRESET, '00', '25'), '');
|
||||
assert.match(spKeysetCheck(PRESET, '00', '35'), /3 .*not defined/);
|
||||
// without a preset only the byte rules apply (hand-entered keys)
|
||||
assert.strictEqual(spKeysetCheck(null, '35', '35'), '');
|
||||
assert.match(spKeysetCheck(null, '15', '25'), /15 \/ 25/);
|
||||
// malformed bytes are named
|
||||
assert.match(spKeysetCheck(PRESET, '5', '15'), /KIc/);
|
||||
assert.match(spKeysetCheck(PRESET, '15', 'ZZ'), /KID/);
|
||||
});
|
||||
|
||||
test('spTarKeysetGuard follows the TAR default keyset (v3.26.0)', () => {
|
||||
const preset = Object.assign({}, PRESET, { tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
|
||||
{ role: 'uiccRfm', tar: 'B00000', msl: '16', keyset: 2 },
|
||||
{ role: 'usimRfm', tar: 'B00001', msl: '00' },
|
||||
{ tar: 'B00011', msl: '16', keyset: 3 }] });
|
||||
assert.strictEqual(spTarKeysetGuard(preset, '000000'), '');
|
||||
assert.strictEqual(spTarKeysetGuard(preset, 'B00001'), ''); // MSL 00: keyless is fine
|
||||
// a TAR naming a keyset the preset does not define
|
||||
assert.match(spTarKeysetGuard(preset, 'B00011'), /is not defined in the preset/);
|
||||
// a security TAR without a default keyset
|
||||
const noKeyset = Object.assign({}, PRESET, { tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16' }] });
|
||||
assert.match(spTarKeysetGuard(noKeyset, '000000'), /needs a default keyset/);
|
||||
// a TAR outside the preset is the server's business; no preset: nothing
|
||||
assert.strictEqual(spTarKeysetGuard(preset, 'B00099'), '');
|
||||
assert.strictEqual(spTarKeysetGuard(null, '000000'), '');
|
||||
});
|
||||
|
||||
test('the Cards form wires the keyset editor', () => {
|
||||
assert.match(html, /id="cards-keysets"/);
|
||||
assert.match(html, /onclick="cardsKeysetAdd\(\)"/);
|
||||
assert.match(html, /oninput="cardsKeysetKvnUpdate\(/);
|
||||
assert.match(html, /data-l10n="Add keyset"/);
|
||||
// the remove button is the red action style, like the preset list's Remove
|
||||
assert.match(html, /cardsKeysetRemove\(' \+ i \+ '\)" class="[^"]*bg-red-600 text-white/);
|
||||
// the keyset rows are built with the KIc/KID/key/counter inputs
|
||||
assert.match(html, /cards-ks-' \+ i \+ '-kic-key/);
|
||||
assert.match(html, /cards-ks-' \+ i \+ '-cntr/);
|
||||
// v3.26.0: the keyset selectors are gone - the SCP80 TAR select drives
|
||||
// the keyset, the RAM operations use the preset's ISD entry, and the TAR
|
||||
// table gained a keyset column
|
||||
assert.match(html, /id="sp-tar" onchange="spTarChanged\(\)"/);
|
||||
assert.ok(!html.includes('id="sp-keyset-sel"'));
|
||||
assert.ok(!html.includes('id="ram-keyset-sel"'));
|
||||
assert.ok(!html.includes('id="tar-keyset-sel"'));
|
||||
assert.match(html, /id="cards-tr-' \+ i \+ '-keyset"/);
|
||||
// the packet actions run the guard
|
||||
assert.ok(html.includes('const ksErr = spKeysetGuard();'));
|
||||
assert.ok(html.includes('const ksErr = ramKeysetGuard();'));
|
||||
});
|
||||
|
||||
test('spCounterTracked follows SPI1 b5b4 (TS 102 225 5.1.1)', () => {
|
||||
// b5b4 = 00: "present, ignored, never updated" - no counter to track
|
||||
for (const spi1 of ['00', '01', '06', '']) {
|
||||
assert.strictEqual(spCounterTracked(spi1), false, spi1);
|
||||
}
|
||||
for (const spi1 of ['08', '10', '16', '18', '1E']) {
|
||||
assert.strictEqual(spCounterTracked(spi1), true, spi1);
|
||||
}
|
||||
assert.strictEqual(spCounterTracked('zz'), false);
|
||||
});
|
||||
|
||||
test('ramRender keeps the remembered preset (v3.26.0: no keyset selector)', () => {
|
||||
const els = {
|
||||
'ram-card-sel': { value: '', innerHTML: '', appendChild() {} },
|
||||
'sp-kic-hex': { value: '25' },
|
||||
'sp-kid-hex': { value: '25' },
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: id => els[id] || null,
|
||||
createElement: () => ({ value: '', textContent: '' }),
|
||||
};
|
||||
globalThis.cards = [{ name: 'EP', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' },
|
||||
{ kic: '25', kid: '25', kicKey: 'CC', kidKey: 'DD', cntr: '0000000184' }] }];
|
||||
globalThis._ramCardIdx = 0;
|
||||
globalThis.ramOpChanged = () => {};
|
||||
globalThis.ramExpandedDetailsInit = () => {};
|
||||
ramRender();
|
||||
assert.strictEqual(els['ram-card-sel'].value, '0',
|
||||
'the preset select keeps the remembered card');
|
||||
});
|
||||
|
||||
test('the RAM and TAR views rebuild their keyset lists when their preset is known', () => {
|
||||
// ramRender() syncs the keyset selectors once the preset is set ...
|
||||
const ram = html.slice(html.indexOf('function ramRender()'),
|
||||
html.indexOf('function ramRender()') + 1400);
|
||||
assert.ok(ram.includes('spKeysetSync();'), 'ramRender must rebuild the keyset lists');
|
||||
// ... tarRender() already does, and keeps the remembered selection
|
||||
const tar = html.slice(html.indexOf('function tarRender()'),
|
||||
html.indexOf('function tarRender()') + 1200);
|
||||
assert.ok(tar.includes('spKeysetSync();'));
|
||||
assert.ok(tar.includes(': _ramCardIdx;'), 'tarRender keeps the remembered preset');
|
||||
// opening the TAR pill populates both its selectors
|
||||
assert.ok(html.includes("if (name === 'tar') { tarRender(); tarProbeRender(); }"));
|
||||
});
|
||||
|
||||
test('spTarChanged applies the TAR keyset and clears it for a keyless TAR', () => {
|
||||
// v3.26.0 review: a TAR without a default keyset (MSL 00) must reset the
|
||||
// form to the no-security '00/00' form - a stale previous TAR's KIc/KID
|
||||
// would otherwise travel explicitly and build a non-keyless packet
|
||||
globalThis.updateKeyPlaceholder = () => {};
|
||||
globalThis.spInvalidate = () => {};
|
||||
globalThis.updateSp = () => {};
|
||||
globalThis.spCntrPlaceholder = () => {};
|
||||
const els = {
|
||||
'sp-tar': { value: '', disabled: false, innerHTML: '', options: [] },
|
||||
'sp-card-sel': { value: '0' },
|
||||
'sp-kic-idx': { value: '' }, 'sp-kic-alg': { value: '' }, 'sp-kic-hex': { value: '' },
|
||||
'sp-kid-idx': { value: '' }, 'sp-kid-alg': { value: '' }, 'sp-kid-hex': { value: '' },
|
||||
'sp-kic-key': { value: '' }, 'sp-kid-key': { value: '' }, 'sp-cntr': { value: 'X' },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'EP', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }],
|
||||
tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
|
||||
{ tar: 'B00011', msl: '00' }] }];
|
||||
// a keyset TAR applies its KIc/KID and keys (the counter override clears)
|
||||
els['sp-tar'].value = '000000';
|
||||
spTarChanged();
|
||||
assert.strictEqual(els['sp-kic-hex'].value, '15');
|
||||
assert.strictEqual(els['sp-kid-hex'].value, '15');
|
||||
assert.strictEqual(els['sp-kic-key'].value, 'AA');
|
||||
assert.strictEqual(els['sp-cntr'].value, '');
|
||||
// a keyless TAR (MSL 00, no default keyset) resets to the no-security form
|
||||
els['sp-kic-key'].value = 'AA';
|
||||
els['sp-tar'].value = 'B00011';
|
||||
spTarChanged();
|
||||
assert.strictEqual(els['sp-kic-hex'].value, '00');
|
||||
assert.strictEqual(els['sp-kid-hex'].value, '00');
|
||||
assert.strictEqual(els['sp-kic-key'].value, '');
|
||||
});
|
||||
|
||||
test('the TAR selector waits for a preset and lists its entries', () => {
|
||||
const els = {
|
||||
'sp-tar': { value: '', disabled: false, innerHTML: '', options: [] },
|
||||
'sp-card-sel': { value: '' },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [];
|
||||
spTarRender();
|
||||
assert.strictEqual(els['sp-tar'].disabled, true, 'no preset: the selector waits');
|
||||
assert.match(els['sp-tar'].innerHTML, /select a card preset/);
|
||||
// a selected preset enables the selector and lists its TARs with their
|
||||
// MSL and default keyset
|
||||
globalThis.cards = [{ name: 'EP', tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }];
|
||||
els['sp-card-sel'].value = '0';
|
||||
spTarRender();
|
||||
assert.strictEqual(els['sp-tar'].disabled, false);
|
||||
assert.match(els['sp-tar'].innerHTML, /value="000000"[^>]*>000000 \u00b7 ISD \u00b7 MSL 16 \u00b7 Keyset 1</);
|
||||
assert.match(els['sp-tar'].innerHTML, /value="AF4D02"[^>]*>AF4D02 \u00b7 My applet \u00b7 MSL 0A</);
|
||||
});
|
||||
|
||||
test('clearing the preset selection resets the TAR selector', () => {
|
||||
const at = html.indexOf('function cardsApply(');
|
||||
assert.ok(at > 0);
|
||||
const body = html.slice(at, at + 600);
|
||||
assert.ok(body.includes('spTarRender(null);'),
|
||||
'cardsApply must reset the TAR selector when no preset is selected');
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// The pure state helpers (they only read the NAV_FIELDS constant).
|
||||
eval(html.match(/const NAV_FIELDS = \[[\s\S]*?\];/)[0].replace('const ', 'var '));
|
||||
eval(extractFunc(html, 'navStateKey'));
|
||||
|
||||
test('navStateKey canonicalises the view state', () => {
|
||||
assert.strictEqual(navStateKey(null), navStateKey({}));
|
||||
assert.strictEqual(navStateKey({ tab: 'cards' }),
|
||||
navStateKey({ tab: 'cards', preset: '', view: '' }));
|
||||
assert.notStrictEqual(navStateKey({ tab: 'cards', view: 'list' }),
|
||||
navStateKey({ tab: 'cards', view: 'edit' }));
|
||||
// the field order does not matter
|
||||
assert.strictEqual(navStateKey({ tab: 'a', suite: 'x' }),
|
||||
navStateKey({ suite: 'x', tab: 'a' }));
|
||||
// every field participates in the identity
|
||||
const base = { tab: 'phone', sub: 'test', view: 'script', suite: 's', script: 'c' };
|
||||
const baseKey = navStateKey(base);
|
||||
for (const f of ['tab', 'sub', 'parser', 'view', 'listtab', 'preset', 'suite', 'script', 'item']) {
|
||||
const st = Object.assign({}, base);
|
||||
st[f] = 'other';
|
||||
assert.notStrictEqual(navStateKey(st), baseKey, f + ' must change the key');
|
||||
}
|
||||
});
|
||||
|
||||
test('navCurrent builds the state from the view variables', () => {
|
||||
// the module view variables + the one helper it calls, stubbed
|
||||
eval(`var _activeTab = 'c-apdu', _scp80Sub = 'sp', _capduSub = 'sim', _parserSub = 'parse',
|
||||
_phoneSub = 'phone', _scp81Sub = 'listener', _pysimSub = 'files', _cardsView = 'list',
|
||||
_cardsEditId = null, _testView = 'suites', _testScriptId = null, _navSuite = null,
|
||||
profilerView = 'list', profilerListTab = 'profiles', profilerEditId = null,
|
||||
snapshotViewId = null;
|
||||
function testCurrentSuite() { return _navSuite; }`);
|
||||
eval(extractFunc(html, 'navCurrent'));
|
||||
_activeTab = 'scp80'; _scp80Sub = 'ram';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'scp80', sub: 'ram' });
|
||||
_activeTab = 'c-apdu'; _capduSub = 'parser'; _parserSub = 'response';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'c-apdu', sub: 'parser', parser: 'response' });
|
||||
_activeTab = 'phone'; _phoneSub = 'test'; _testView = 'script';
|
||||
_navSuite = { id: 'S' }; _testScriptId = 'C';
|
||||
assert.deepStrictEqual(navCurrent(),
|
||||
{ tab: 'phone', sub: 'test', view: 'script', suite: 'S', script: 'C' });
|
||||
_activeTab = 'pysim'; _pysimSub = 'apdu';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'pysim', sub: 'apdu' });
|
||||
_activeTab = 'cards'; _cardsView = 'edit'; _cardsEditId = 'P';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'cards', view: 'edit', preset: 'P' });
|
||||
// a new-preset editor carries no id
|
||||
_cardsEditId = null;
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'cards', view: 'edit' });
|
||||
_activeTab = 'profiler'; profilerView = 'snapshot'; snapshotViewId = 'N';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'profiler', view: 'snapshot', item: 'N' });
|
||||
profilerView = 'list'; profilerListTab = 'custom';
|
||||
assert.deepStrictEqual(navCurrent(), { tab: 'profiler', view: 'list', listtab: 'custom' });
|
||||
});
|
||||
|
||||
test('the view switches record history, the back buttons replace it', () => {
|
||||
// every user-facing switch records its view
|
||||
for (const fn of ['switchTab', 'scp80SwitchSubtab', 'cApduSwitchSubtab',
|
||||
'parserSwitchSubtab', 'phoneSwitchSubtab', 'scp81SwitchSubtab',
|
||||
'pysimSwitchSubtab', 'cardsShowList', 'cardsShowEditor', 'testViewShow',
|
||||
'profilerSetView', 'profilerListSwitch']) {
|
||||
assert.match(extractFunc(html, fn), /navRecord\(\)/, fn + ' must record');
|
||||
}
|
||||
// the view variables the state is built from
|
||||
assert.match(extractFunc(html, 'switchTab'), /_activeTab = name/);
|
||||
assert.match(extractFunc(html, 'scp80SwitchSubtab'), /_scp80Sub = name/);
|
||||
assert.match(extractFunc(html, 'cApduSwitchSubtab'), /_capduSub = name/);
|
||||
assert.match(extractFunc(html, 'phoneSwitchSubtab'), /_phoneSub = name/);
|
||||
assert.match(extractFunc(html, 'scp81SwitchSubtab'), /_scp81Sub = name/);
|
||||
assert.match(extractFunc(html, 'pysimSwitchSubtab'), /_pysimSub = name/);
|
||||
assert.match(extractFunc(html, 'cardsShowList'), /_cardsView = 'list'/);
|
||||
assert.match(extractFunc(html, 'cardsShowEditor'), /_cardsView = 'edit'/);
|
||||
assert.match(extractFunc(html, 'cardsEdit'), /_cardsEditId = c\.id/);
|
||||
assert.match(extractFunc(html, 'cardsNew'), /_cardsEditId = null/);
|
||||
// the nested parser switch must not record two entries (the target sub is
|
||||
// set before the recursion)
|
||||
const capdu = extractFunc(html, 'cApduSwitchSubtab');
|
||||
assert.match(capdu, /_parserSub = name;\s*cApduSwitchSubtab\('parser'\)/);
|
||||
// the in-app Cancel/Back (and the profiler save) replace their entry
|
||||
for (const fn of ['cardsCancelEdit', 'profilerBackToList', 'testScriptBack',
|
||||
'profilerSaveProfile']) {
|
||||
assert.match(extractFunc(html, fn), /navReplace\(\)/, fn + ' must replace');
|
||||
}
|
||||
// the router core: seeded, guarded, replayed through the switch functions
|
||||
assert.match(html, /window\.addEventListener\('popstate'/);
|
||||
assert.match(html, /history\.pushState\(/);
|
||||
assert.match(html, /history\.replaceState\(/);
|
||||
assert.match(html, /navSeed\(\);/);
|
||||
const apply = extractFunc(html, 'navApply');
|
||||
assert.match(apply, /_navApplying = true/);
|
||||
assert.match(apply, /switchTab\(/);
|
||||
assert.match(apply, /pysimSwitchSubtab\(/);
|
||||
assert.match(apply, /navApplyTest\(/);
|
||||
assert.match(apply, /navApplyCards\(/);
|
||||
assert.match(apply, /navApplyProfiler\(/);
|
||||
// a throwing replay falls back instead of escaping the popstate handler
|
||||
assert.match(apply, /catch \(e\)/);
|
||||
for (const fn of ['navApplyTest', 'navApplyCards', 'navApplyProfiler', 'testBackToSuites']) {
|
||||
assert.match(html, new RegExp('function ' + fn + '\\('), fn + ' must exist');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const f of ['netSimRandomHex', 'netSimDice', 'netSimVal', 'netSimChk', 'netSimParams', 'netSimStepText', 'netSimHomeOperator']) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
code += 'function t(k){return k;}\n';
|
||||
code += 'var _fields = {};\nvar document = { getElementById: function(id){ return _fields[id] || null; } };\n';
|
||||
eval(code);
|
||||
|
||||
test('netSimRandomHex produces uppercase hex of the requested length', () => {
|
||||
const h = netSimRandomHex(4);
|
||||
assert.match(h, /^[0-9A-F]{8}$/);
|
||||
assert.match(netSimRandomHex(1), /^[0-9A-F]{2}$/);
|
||||
});
|
||||
|
||||
test('netSimParams maps form fields and checkboxes', () => {
|
||||
_fields = {
|
||||
'netsim-mcc': { value: '262' },
|
||||
'netsim-mnc': { value: '01' },
|
||||
'netsim-lac': { value: ' 6CD7 ' },
|
||||
'netsim-lac-empty': { value: '' },
|
||||
'netsim-churn-count': { value: '5' },
|
||||
'netsim-send-event': { checked: true },
|
||||
'netsim-dummy-locations': { checked: false },
|
||||
'netsim-invalidate-epsnsc': { checked: true },
|
||||
'netsim-keep-kasme': { checked: true },
|
||||
'netsim-write-kc': { checked: false },
|
||||
'netsim-sms-location': { checked: true },
|
||||
'netsim-cb-clear': { checked: false },
|
||||
};
|
||||
const p = netSimParams();
|
||||
assert.strictEqual(p.mcc, '262');
|
||||
assert.strictEqual(p.mnc, '01');
|
||||
assert.strictEqual(p.lac, '6CD7');
|
||||
assert.strictEqual(p.churn_count, '5');
|
||||
assert.strictEqual(p.send_event, true);
|
||||
assert.strictEqual(p.dummy_locations, false);
|
||||
assert.strictEqual(p.invalidate_epsnsc, true);
|
||||
assert.strictEqual(p.keep_kasme, true);
|
||||
assert.strictEqual(p.write_kc, false);
|
||||
assert.strictEqual(p.sms_location, true);
|
||||
assert.strictEqual(p.cb_clear, false);
|
||||
// unset fields are omitted so the server fills its own defaults
|
||||
assert.ok(!('tmsi' in p));
|
||||
assert.ok(!('kasme' in p));
|
||||
});
|
||||
|
||||
test('netSimStepText renders the step log lines', () => {
|
||||
assert.strictEqual(
|
||||
netSimStepText({ action: 'update_binary', file: 'loci', path: 'ADF.USIM/6F7E', data: 'FF01', sw: '9000' }),
|
||||
'update_binary loci (ADF.USIM/6F7E) FF01 → SW 9000');
|
||||
assert.strictEqual(
|
||||
netSimStepText({ action: 'update_record', file: 'epsnsc', path: 'ADF.USIM/6FE4', data: 'A0', sw: '9000' }),
|
||||
'update_record epsnsc (ADF.USIM/6FE4) A0 → SW 9000');
|
||||
assert.strictEqual(
|
||||
netSimStepText({ action: 'event', file: 'location_status', data: '9B0102', sw: '9000' }),
|
||||
'ENVELOPE location_status 9B0102 → SW 9000');
|
||||
assert.strictEqual(
|
||||
netSimStepText({ action: 'skip', file: 'location_status', note: 'event 0x03 not in SET UP EVENT LIST' }),
|
||||
'skip location_status — event 0x03 not in SET UP EVENT LIST');
|
||||
assert.match(
|
||||
netSimStepText({ action: 'authenticate', parsed: { type: 'synchronisation_failure' }, sw: '9000', response: 'DC10AA' }),
|
||||
/^AUTHENTICATE synchronisation_failure → SW 9000 DC10AA$/);
|
||||
});
|
||||
|
||||
test('netSimHomeOperator fills the home PLMN and reports its source', async () => {
|
||||
_fields = {
|
||||
'netsim-mcc': { value: '' },
|
||||
'netsim-mnc': { value: '' },
|
||||
'netsim-status': { textContent: '', className: '' },
|
||||
};
|
||||
globalThis.pysimFetch = async () => ({
|
||||
available: true,
|
||||
state: { network: { home: { mcc: '250', mnc: '99', source: 'hplmnwact' } } },
|
||||
});
|
||||
await netSimHomeOperator();
|
||||
assert.strictEqual(_fields['netsim-mcc'].value, '250');
|
||||
assert.strictEqual(_fields['netsim-mnc'].value, '99');
|
||||
assert.ok(_fields['netsim-status'].textContent.includes('250/99'));
|
||||
assert.ok(_fields['netsim-status'].textContent.includes('from EF.HPLMNwAcT'));
|
||||
assert.ok(_fields['netsim-status'].className.includes('text-gray-500'));
|
||||
});
|
||||
|
||||
test('netSimHomeOperator reports the IMSI source and warns when unavailable', async () => {
|
||||
_fields = {
|
||||
'netsim-mcc': { value: '' },
|
||||
'netsim-mnc': { value: '' },
|
||||
'netsim-status': { textContent: '', className: '' },
|
||||
};
|
||||
globalThis.pysimFetch = async () => ({
|
||||
available: true,
|
||||
state: { network: { home: { mcc: '228', mnc: '06', source: 'imsi' } } },
|
||||
});
|
||||
await netSimHomeOperator();
|
||||
assert.ok(_fields['netsim-status'].textContent.includes('from IMSI'));
|
||||
// no home PLMN -> the fields stay and the status warns
|
||||
_fields['netsim-mcc'].value = '001';
|
||||
_fields['netsim-mnc'].value = '01';
|
||||
globalThis.pysimFetch = async () => ({ available: true, state: { network: { home: null } } });
|
||||
await netSimHomeOperator();
|
||||
assert.strictEqual(_fields['netsim-mcc'].value, '001');
|
||||
assert.strictEqual(_fields['netsim-mnc'].value, '01');
|
||||
assert.ok(_fields['netsim-status'].textContent.includes('not available'));
|
||||
assert.ok(_fields['netsim-status'].className.includes('text-yellow-600'));
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['netStateStatusWord', 'netStateFlattenTitle', 'netStatePlmnList',
|
||||
'netStateSummary', 'netStateRender']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
const CONSTS = {
|
||||
NET_STATE_FILES: ['[', '];'],
|
||||
NET_STATE_STATUS: ['{', '};'],
|
||||
NET_STATE_AREA: ['{', '};'],
|
||||
NET_STATE_ROAMING: ['{', '};'],
|
||||
NET_STATE_SERVICE: ['{', '};'],
|
||||
NET_STATE_SOURCE: ['{', '};'],
|
||||
};
|
||||
for (const [name, [open, close]] of Object.entries(CONSTS)) {
|
||||
const re = new RegExp('const ' + name + ' = \\' + open + '[\\s\\S]*?\\' + close);
|
||||
const m = re.exec(html);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
code += m[0].replace(/^const /, 'var ') + '\n';
|
||||
}
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
code += 'globalThis.esc = s => String(s);\n';
|
||||
code += 'globalThis.efFlatten = d => Object.entries(d || {}).map(([k, v]) => [k, String(v)]);\n';
|
||||
eval(code);
|
||||
|
||||
function setup() {
|
||||
const els = {};
|
||||
for (const id of ['netstate-badge', 'netstate-location',
|
||||
'netstate-read', 'netstate-body']) {
|
||||
els[id] = { className: '', textContent: '', innerHTML: '' };
|
||||
}
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
function decoder(map) {
|
||||
globalThis.efDecodeFile = (name, fid, content) =>
|
||||
map[name] ? { data: map[name] } : null;
|
||||
}
|
||||
|
||||
test('netStateSummary renders decoded file contents compactly', () => {
|
||||
setup();
|
||||
decoder({
|
||||
'EF.LOCI': { tmsi: '3E905D6B', mcc: '262', mnc: '01', lac: '0x6CD7',
|
||||
tmsi_time: '0xFF', location_update_status: '0x00' },
|
||||
'EF.FPLMN': { plmns: [{ mcc: '262', mnc: '01' }, { mcc: '246', mnc: '81' }] },
|
||||
'EF.HPLMNwAcT': { plmns: [{ mcc: '262', mnc: '01', access_tech: 'LTE' }] },
|
||||
});
|
||||
const loci = netStateSummary('loci', { present: true, kind: 'transparent', data: 'AA' });
|
||||
assert.strictEqual(loci.text, 'LAI 262-01/6CD7 · updated');
|
||||
assert.ok(loci.title.includes('3E905D6B'));
|
||||
const fplmn = netStateSummary('fplmn', { present: true, kind: 'transparent', data: 'AA' });
|
||||
assert.strictEqual(fplmn.text, '262-01, 246-81');
|
||||
const hplmn = netStateSummary('hplmnwact', { present: true, kind: 'transparent', data: 'AA' });
|
||||
assert.strictEqual(hplmn.text, '262-01');
|
||||
const absent = netStateSummary('loci', { present: false });
|
||||
assert.strictEqual(absent.text, '—');
|
||||
assert.strictEqual(absent.title, 'not present');
|
||||
});
|
||||
|
||||
test('netStateSummary marks rejection statuses', () => {
|
||||
setup();
|
||||
decoder({
|
||||
'EF.LOCI': { mcc: '246', mnc: '81', lac: '0xFFFE', location_update_status: '0x02' },
|
||||
});
|
||||
const loci = netStateSummary('loci', { present: true, kind: 'transparent', data: 'AA' });
|
||||
assert.strictEqual(loci.text, 'LAI 246-81/FFFE · PLMN not allowed');
|
||||
});
|
||||
|
||||
test('netStatePlmnList limits long lists with a count', () => {
|
||||
const d = { plmns: [
|
||||
{ mcc: '262', mnc: '01' }, { mcc: '246', mnc: '81' },
|
||||
{ mcc: '204', mnc: '04' }, { mcc: '234', mnc: '15' },
|
||||
] };
|
||||
assert.strictEqual(netStatePlmnList(d, false, 3), '262-01, 246-81, 204-04 … +1');
|
||||
assert.strictEqual(netStatePlmnList(d, false), '262-01, 246-81, 204-04, 234-15');
|
||||
});
|
||||
|
||||
test('netStateSummary abbreviates a long HPLMNwAcT list', () => {
|
||||
setup();
|
||||
decoder({
|
||||
'EF.HPLMNwAcT': { plmns: [
|
||||
{ mcc: '250', mnc: '99', access_tech: 'LTE' },
|
||||
{ mcc: '250', mnc: '32', access_tech: 'LTE' },
|
||||
{ mcc: '250', mnc: '54', access_tech: 'LTE' },
|
||||
{ mcc: '262', mnc: '01', access_tech: 'GSM' },
|
||||
{ mcc: '246', mnc: '81', access_tech: 'GSM' },
|
||||
] },
|
||||
});
|
||||
const s = netStateSummary('hplmnwact', { present: true, kind: 'transparent', data: 'AA' });
|
||||
// first network only (no access tech in the row), plus the hidden count
|
||||
assert.strictEqual(s.text, '250-99 … +4');
|
||||
});
|
||||
|
||||
test('netStateRender paints the service badge, location and rows', () => {
|
||||
const els = setup();
|
||||
decoder({ 'EF.IMSI': { imsi: '262011234567890' } });
|
||||
netStateRender({
|
||||
service: { state: 'limited', source: 'net-sim:roaming_denied' },
|
||||
read_at: 1700000000,
|
||||
files: { imsi: { present: true, kind: 'transparent', data: 'AA', source: 'write' } },
|
||||
network: { location: { mcc: '262', mnc: '01', country: 'Germany',
|
||||
operator: 'Telekom', roaming: 'guest', rejected: true,
|
||||
area: 'LAI', lac: '6CD7', source: 'write' } },
|
||||
});
|
||||
assert.strictEqual(els['netstate-badge'].textContent, '⚠ Limited service');
|
||||
assert.ok(els['netstate-badge'].className.includes('text-amber-600'));
|
||||
assert.ok(els['netstate-location'].innerHTML.includes('262-01'));
|
||||
assert.ok(els['netstate-location'].innerHTML.includes('Germany'));
|
||||
assert.ok(els['netstate-location'].innerHTML.includes('Guest (roaming)'));
|
||||
assert.ok(els['netstate-location'].innerHTML.includes('PLMN not allowed'));
|
||||
assert.ok(els['netstate-location'].innerHTML.includes(
|
||||
'Telekom<span class="block">Guest (roaming) · <span class="text-red-500">⛔ PLMN not allowed</span></span>'));
|
||||
assert.ok(!els['netstate-location'].innerHTML.includes('Telekom · '));
|
||||
assert.strictEqual(els['netstate-read'].textContent, new Date(1700000000 * 1000).toLocaleTimeString());
|
||||
assert.ok(els['netstate-body'].innerHTML.includes('EF.IMSI'));
|
||||
assert.ok(els['netstate-body'].innerHTML.includes('262011234567890'));
|
||||
assert.ok(els['netstate-body'].innerHTML.includes('>write<'));
|
||||
});
|
||||
|
||||
test('netStateRender keeps the location on one line when there is no status', () => {
|
||||
const els = setup();
|
||||
decoder({});
|
||||
netStateRender({ service: { state: 'normal' }, files: {},
|
||||
network: { location: { mcc: '262', mnc: '01', country: 'Germany',
|
||||
operator: 'Telekom', area: 'LAI', lac: '6CD7' } } });
|
||||
assert.strictEqual(els['netstate-location'].innerHTML,
|
||||
'<span class="font-mono">262-01</span> · Germany · Telekom');
|
||||
});
|
||||
|
||||
test('netStateRender shows Undefined until something is simulated', () => {
|
||||
const els = setup();
|
||||
decoder({});
|
||||
netStateRender(null);
|
||||
assert.strictEqual(els['netstate-badge'].textContent, '○ Undefined');
|
||||
assert.ok(els['netstate-badge'].className.includes('text-gray-400'));
|
||||
assert.ok(els['netstate-body'].innerHTML.includes('EF.LOCI'));
|
||||
assert.ok(els['netstate-body'].innerHTML.includes('—'));
|
||||
});
|
||||
|
||||
test('netStateRender shows the green normal-service badge', () => {
|
||||
const els = setup();
|
||||
decoder({});
|
||||
netStateRender({ service: { state: 'normal' }, files: {} });
|
||||
assert.strictEqual(els['netstate-badge'].textContent, '● Normal service');
|
||||
assert.ok(els['netstate-badge'].className.includes('text-emerald-600'));
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"_comment": "EF.PBR (TS 31.102 4.4.2.1) decode vectors, shared by the PWA decoder (frontend/tests/ef_decode.test.js, efDecPbr) and the server parser (tests/test_pbr.py, _pbr_parse) - the drift guard for the two implementations. 'records' are raw EF_PBR record hex strings (as /api/pbr serves them); 'master' is the first FID of the first 'A8' container (the PWA decoder only - the server parser does not need it); 'links' is the PWA decoder's link list (tag/kind/fid/sfi, plus 'raw' for a preserved unknown tag); 'files' is the referenced FID list in record order, deduplicated by FID - the shape the server parser returns.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "pySim's EF_PBR fixture: type 1 and type 3 containers, every primitive with an SFI",
|
||||
"records": [
|
||||
"A80AC0034F3A02C5034F0904AA0ACB034F3D07C2034F4A06"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": "02"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C5",
|
||||
"kind": "EF.PBC",
|
||||
"fid": "4F09",
|
||||
"sfi": "04"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "CB",
|
||||
"kind": "EF.CCP1",
|
||||
"fid": "4F3D",
|
||||
"sfi": "07"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "C2",
|
||||
"kind": "EF.EXT1",
|
||||
"fid": "4F4A",
|
||||
"sfi": "06"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A",
|
||||
"4F09",
|
||||
"4F3D",
|
||||
"4F4A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type 2 ('A9') addressed through EF_IAP (a 'C1' inside the 'A8'); FID-only primitives (no SFI)",
|
||||
"records": [
|
||||
"A808C0024F3AC1024F09A90AC6034F0B06C7034F0C07"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": null
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C1",
|
||||
"kind": "EF.IAP",
|
||||
"fid": "4F09",
|
||||
"sfi": null
|
||||
},
|
||||
{
|
||||
"link": 2,
|
||||
"tag": "C6",
|
||||
"kind": "EF.GRP",
|
||||
"fid": "4F0B",
|
||||
"sfi": "06"
|
||||
},
|
||||
{
|
||||
"link": 2,
|
||||
"tag": "C7",
|
||||
"kind": "EF.AAS",
|
||||
"fid": "4F0C",
|
||||
"sfi": "07"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A",
|
||||
"4F09",
|
||||
"4F0B",
|
||||
"4F0C"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unused trailing bytes are FF: the padding is not a link",
|
||||
"records": [
|
||||
"A80AC0034F3A02C5034F0904AA0ACB034F3D07C2034F4A06FFFFFFFFFFFFFFFF"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": "02"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C5",
|
||||
"kind": "EF.PBC",
|
||||
"fid": "4F09",
|
||||
"sfi": "04"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "CB",
|
||||
"kind": "EF.CCP1",
|
||||
"fid": "4F3D",
|
||||
"sfi": "07"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "C2",
|
||||
"kind": "EF.EXT1",
|
||||
"fid": "4F4A",
|
||||
"sfi": "06"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A",
|
||||
"4F09",
|
||||
"4F3D",
|
||||
"4F4A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "a truncated trailing TLV (a lone 'C5' tag): the links before it are kept",
|
||||
"records": [
|
||||
"A80AC0034F3A02C5034F0904C5"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": "02"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C5",
|
||||
"kind": "EF.PBC",
|
||||
"fid": "4F09",
|
||||
"sfi": "04"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A",
|
||||
"4F09"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "an unknown primitive tag ('CE') is preserved raw and is not a phonebook file",
|
||||
"records": [
|
||||
"A80AC0034F3A02CE034F0904"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": "02"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "CE",
|
||||
"kind": null,
|
||||
"fid": "4F09",
|
||||
"sfi": "04",
|
||||
"raw": "4F0904"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "TS 31.102 Annex G Table G.2 Rec 1 (every kind tag; the printed A8 length is internally inconsistent - the table lists 10 primitives of 5 bytes = 50 = 0x32 - so the corrected length is used)",
|
||||
"records": [
|
||||
"A832C0034F3A01C5034F0902C6034F2603C4034F1104C4034F1305C4034F1506C3034F1907C9034F2112CA034F5009CC034F5416AA0FC7034F4B14C8034F4C15C2034F4A08"
|
||||
],
|
||||
"master": "4F3A",
|
||||
"links": [
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C0",
|
||||
"kind": "EF.ADN",
|
||||
"fid": "4F3A",
|
||||
"sfi": "01"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C5",
|
||||
"kind": "EF.PBC",
|
||||
"fid": "4F09",
|
||||
"sfi": "02"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C6",
|
||||
"kind": "EF.GRP",
|
||||
"fid": "4F26",
|
||||
"sfi": "03"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C4",
|
||||
"kind": "EF.ANR",
|
||||
"fid": "4F11",
|
||||
"sfi": "04"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C4",
|
||||
"kind": "EF.ANR",
|
||||
"fid": "4F13",
|
||||
"sfi": "05"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C4",
|
||||
"kind": "EF.ANR",
|
||||
"fid": "4F15",
|
||||
"sfi": "06"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C3",
|
||||
"kind": "EF.SNE",
|
||||
"fid": "4F19",
|
||||
"sfi": "07"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "C9",
|
||||
"kind": "EF.UID",
|
||||
"fid": "4F21",
|
||||
"sfi": "12"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "CA",
|
||||
"kind": "EF.EMAIL",
|
||||
"fid": "4F50",
|
||||
"sfi": "09"
|
||||
},
|
||||
{
|
||||
"link": 1,
|
||||
"tag": "CC",
|
||||
"kind": "EF.PURI",
|
||||
"fid": "4F54",
|
||||
"sfi": "16"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "C7",
|
||||
"kind": "EF.AAS",
|
||||
"fid": "4F4B",
|
||||
"sfi": "14"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "C8",
|
||||
"kind": "EF.GAS",
|
||||
"fid": "4F4C",
|
||||
"sfi": "15"
|
||||
},
|
||||
{
|
||||
"link": 3,
|
||||
"tag": "C2",
|
||||
"kind": "EF.EXT1",
|
||||
"fid": "4F4A",
|
||||
"sfi": "08"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"4F3A",
|
||||
"4F09",
|
||||
"4F26",
|
||||
"4F11",
|
||||
"4F13",
|
||||
"4F15",
|
||||
"4F19",
|
||||
"4F21",
|
||||
"4F50",
|
||||
"4F54",
|
||||
"4F4B",
|
||||
"4F4C",
|
||||
"4F4A"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.setHelpAnchor = a => { globalThis._anchor = a; };\n' +
|
||||
'globalThis.stkCheckMenu = () => { globalThis._stk = (globalThis._stk || 0) + 1; };\n' +
|
||||
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
|
||||
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
|
||||
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
|
||||
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' +
|
||||
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n' +
|
||||
'globalThis.esimFetchAll = () => { globalThis._esim = (globalThis._esim || 0) + 1; };\n' +
|
||||
'globalThis.testInit = () => { globalThis._test = (globalThis._test || 0) + 1; };\n' +
|
||||
'globalThis.bipEnter = () => { globalThis._bip = (globalThis._bip || 0) + 1; };\n' +
|
||||
'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n' +
|
||||
'globalThis.navRecord = function() {};\n';
|
||||
eval(code);
|
||||
|
||||
function makeClassList() {
|
||||
const set = new Set();
|
||||
return {
|
||||
toggle: (c, on) => { on ? set.add(c) : set.delete(c); },
|
||||
has: c => set.has(c),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const buttons = [
|
||||
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'bip' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'esim' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'test' }, classList: makeClassList() },
|
||||
];
|
||||
const panels = {
|
||||
'phone-sub-phone': { classList: makeClassList() },
|
||||
'phone-sub-tr': { classList: makeClassList() },
|
||||
'phone-sub-bip': { classList: makeClassList() },
|
||||
'phone-sub-esim': { classList: makeClassList() },
|
||||
'phone-sub-test': { classList: makeClassList() },
|
||||
};
|
||||
globalThis.document = {
|
||||
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
|
||||
getElementById: id => panels[id] || null,
|
||||
};
|
||||
globalThis._anchor = null;
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
|
||||
globalThis._esim = globalThis._netstate = 0;
|
||||
globalThis._test = 0;
|
||||
globalThis._bip = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
test('TR Config pill shows the TR panel and renders PLI data', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('tr');
|
||||
assert.ok(!panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(buttons[1].classList.has('bg-blue-600'));
|
||||
assert.ok(!buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'pli-dict');
|
||||
assert.strictEqual(globalThis._pli, 1);
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('BIP pill shows the BIP panel', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('bip');
|
||||
assert.ok(!panels['phone-sub-bip'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.strictEqual(globalThis._anchor, 'bip');
|
||||
assert.strictEqual(globalThis._bip, 1);
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('Phone pill shows the phone panel and renders CAT views', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('phone');
|
||||
assert.ok(!panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-tr'].classList.has('hidden'));
|
||||
assert.ok(buttons[0].classList.has('bg-blue-600'));
|
||||
assert.strictEqual(globalThis._anchor, 'stk-menu');
|
||||
assert.strictEqual(globalThis._stk, 1);
|
||||
assert.strictEqual(globalThis._events, 1);
|
||||
assert.strictEqual(globalThis._log, 1);
|
||||
assert.strictEqual(globalThis._poll, 1);
|
||||
assert.strictEqual(globalThis._pli, 0);
|
||||
assert.strictEqual(globalThis._tp, 1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
// The codec map's select fields reference ACCESSTECH; only the date/time
|
||||
// codec is exercised here, but the map literal still evaluates that list.
|
||||
eval(extractBlock('const ACCESSTECH = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('// ===== PLI data dictionary =====', 'async function pysimPliRender')
|
||||
.replace(/^const /gm, 'var '));
|
||||
|
||||
test('the PLI date/time codec uses the 8.39 TP-SCTS coding', () => {
|
||||
const c = PLI_CODECS['03'];
|
||||
assert.strictEqual(c.tlv, '2607');
|
||||
// +03:00: swapped semi-octet BCD digits, the time zone in quarters of an
|
||||
// hour (0x21 = 12 quarters) - the eventDateTimeTlv / _encode_scts vectors
|
||||
assert.strictEqual(c.encode({ dt: '2026-09-30', tm: '21:55:00', tz: '+03:00' }),
|
||||
'260762900312550021');
|
||||
assert.deepStrictEqual(c.decode('260762900312550021'),
|
||||
{ dt: '2026-09-30', tm: '21:55:00', tz: '+03:00' });
|
||||
// negative zones carry the sign bit 0x08: -2:30 = 10 quarters -> 0x09
|
||||
assert.strictEqual(c.encode({ dt: '2026-01-05', tm: '08:07:09', tz: '-02:30' }),
|
||||
'260762105080709009');
|
||||
assert.deepStrictEqual(c.decode('260762105080709009'),
|
||||
{ dt: '2026-01-05', tm: '08:07:09', tz: '-02:30' });
|
||||
// 'FF' time zone = unknown (TS 123 040 9.2.3.11), never a bogus offset
|
||||
assert.strictEqual(c.decode('2607629003125500FF').tz, 'unknown');
|
||||
// a truncated value still returns null instead of throwing
|
||||
assert.strictEqual(c.decode('2607123'), null);
|
||||
});
|
||||
|
||||
test('the PLI codec tags follow TS 101 220 Table 7.23, base (CR=0) form', () => {
|
||||
// the TR's response objects are Min = N, so TS 102 223 6.8.0 says they
|
||||
// "should set the CR flag to comprehension not required" (v3.25.2):
|
||||
// the base tag values (13/14/2D/2E/3F/62/65/63/26), not the CR-set forms
|
||||
assert.strictEqual(PLI_CODECS['00'].tlv, '1305');
|
||||
assert.strictEqual(PLI_CODECS['01'].tlv, '1408');
|
||||
assert.strictEqual(PLI_CODECS['01'].encode({ imei: '490154203237518' }), '140894104502237315f8');
|
||||
assert.strictEqual(PLI_CODECS['04'].tlv, '2D02');
|
||||
assert.strictEqual(PLI_CODECS['06'].tlv, '3F01');
|
||||
// 05 Timing Advance: 2E (C6 is a 3GPP2 tag)
|
||||
assert.strictEqual(PLI_CODECS['05'].tlv, '2E02');
|
||||
assert.strictEqual(PLI_CODECS['05'].encode({ status: '0', ta: '0' }), '2E020000');
|
||||
// 08 IMEISV: 62 (94 is the IMEI tag)
|
||||
assert.strictEqual(PLI_CODECS['08'].tlv, '6208');
|
||||
assert.strictEqual(PLI_CODECS['08'].encode({ imeisv: '4901542032375108' }),
|
||||
'62089410450223731580');
|
||||
// 09 Network search mode: 65, '00' Manual / '01' Automatic (8.75)
|
||||
assert.strictEqual(PLI_CODECS['09'].tlv, '6501');
|
||||
assert.strictEqual(PLI_CODECS['09'].fields[0].opts[0].l, 'Manual');
|
||||
assert.strictEqual(PLI_CODECS['09'].fields[0].opts[1].l, 'Automatic');
|
||||
// 0A Battery state: 63/E3 is an enum, not a percentage (8.76)
|
||||
assert.strictEqual(PLI_CODECS['0A'].tlv, '6301');
|
||||
assert.strictEqual(PLI_CODECS['0A'].encode({ charge: '4' }), '630104');
|
||||
assert.strictEqual(PLI_CODECS['0A'].encode({ charge: '0' }), '630100');
|
||||
assert.strictEqual(PLI_CODECS['0E'].tlv, '3F');
|
||||
});
|
||||
|
||||
test('the IMEI encoder pads the odd 15th digit with F (TS 124 008)', () => {
|
||||
// the encoder's digits are lowercase (the server accepts either case)
|
||||
assert.strictEqual(encImeiPli('490154203237518'), '94104502237315f8');
|
||||
assert.strictEqual(decImei('94104502237315F8'), '490154203237518');
|
||||
});
|
||||
|
||||
test('the server PLI defaults decode through their codecs', () => {
|
||||
// mirrors PLI_DEFAULTS in server.py (v3.23.1; a drift guard for the codecs)
|
||||
const defaults = {
|
||||
'00': '130500F1100000', '01': '140894104502237315F8',
|
||||
'04': '2D02656E', '05': '2E020000', '06': '3F0108',
|
||||
'08': '62089410450223731580', '09': '650101',
|
||||
'0A': '630104', '0E': '3F020308',
|
||||
};
|
||||
for (const [code, hex] of Object.entries(defaults)) {
|
||||
assert.ok(PLI_CODECS[code].decode(hex), code + ' must decode');
|
||||
}
|
||||
assert.strictEqual(PLI_CODECS['01'].decode('140894104502237315F8').imei,
|
||||
'490154203237518');
|
||||
assert.deepStrictEqual(PLI_CODECS['09'].decode('650101'), { mode: '1' });
|
||||
assert.deepStrictEqual(PLI_CODECS['0A'].decode('630104'), { charge: '4' });
|
||||
});
|
||||
|
||||
// the same extraction the profiler tests use
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
test('a rejected PLI save is reported, not silently dropped', () => {
|
||||
// the server names the fields it could not use (invalid/unknown) and the
|
||||
// PWA shows them under the Save button (v3.25.6)
|
||||
global.t = s => s;
|
||||
eval(extractFunc(html, 'pysimPliRejectMsg'));
|
||||
assert.strictEqual(pysimPliRejectMsg({}), '');
|
||||
assert.strictEqual(pysimPliRejectMsg({ '0A': '630104' }), '');
|
||||
assert.strictEqual(pysimPliRejectMsg({ invalid: ['0A'] }),
|
||||
'Not saved: 0A (invalid value)');
|
||||
assert.strictEqual(pysimPliRejectMsg({ unknown: ['18'] }),
|
||||
'Not saved: 18 (unknown qualifier)');
|
||||
assert.strictEqual(pysimPliRejectMsg({ invalid: ['0A'], unknown: ['18'] }),
|
||||
'Not saved: 0A (invalid value), 18 (unknown qualifier)');
|
||||
// the save posts every field (no silent client-side skip) and wires the
|
||||
// message element + the reject branch
|
||||
assert.ok(html.includes('if (el) data[q.code] = el.value.trim()'),
|
||||
'every field is sent to the server for validation');
|
||||
assert.ok(html.includes('id="pli-save-msg"'), 'the message element exists');
|
||||
assert.ok(html.includes('const reject = pysimPliRejectMsg(resp);'),
|
||||
'the save reports the rejection');
|
||||
delete global.t;
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'pysimUpdatePollUI'));
|
||||
globalThis.t = s => s;
|
||||
|
||||
function setup() {
|
||||
const els = {
|
||||
'pli-pause-btn': { textContent: '', classList: { add: () => {}, remove: () => {} } },
|
||||
'pli-poll-stat': { textContent: '', className: 'text-xs text-gray-400' },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
test('pysimUpdatePollUI shows the interval while polling is on', () => {
|
||||
const els = setup();
|
||||
pysimUpdatePollUI(true, 30);
|
||||
assert.strictEqual(els['pli-pause-btn'].textContent, 'ON');
|
||||
assert.strictEqual(els['pli-poll-stat'].textContent, '(30s)');
|
||||
assert.ok(!els['pli-poll-stat'].className.includes('amber'));
|
||||
});
|
||||
|
||||
test('pysimUpdatePollUI shows the card-disabled state as OFF in amber', () => {
|
||||
const els = setup();
|
||||
pysimUpdatePollUI(true, 30, true);
|
||||
assert.strictEqual(els['pli-pause-btn'].textContent, 'OFF');
|
||||
assert.ok(els['pli-poll-stat'].textContent.includes('POLLING OFF'), els['pli-poll-stat'].textContent);
|
||||
assert.ok(els['pli-poll-stat'].className.includes('text-amber-600'));
|
||||
});
|
||||
|
||||
test('pysimUpdatePollUI shows the server warning while suspended', () => {
|
||||
const els = setup();
|
||||
pysimUpdatePollUI(true, 30, true, 'card disabled proactive polling');
|
||||
assert.strictEqual(els['pli-pause-btn'].textContent, 'OFF');
|
||||
assert.strictEqual(els['pli-poll-stat'].textContent, 'card disabled proactive polling');
|
||||
});
|
||||
|
||||
test('pysimUpdatePollUI clears the status line when off', () => {
|
||||
const els = setup();
|
||||
pysimUpdatePollUI(false, 30);
|
||||
assert.strictEqual(els['pli-pause-btn'].textContent, 'OFF');
|
||||
assert.strictEqual(els['pli-poll-stat'].textContent, '');
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['swapNibbles', 'encIccid', 'decIccid', 'cardsNormIccid',
|
||||
'spPresetWarning']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
eval(code);
|
||||
|
||||
const DIGITS = '8970119000004600098';
|
||||
const RAW_HEX = '980711090000640090F8';
|
||||
const OTHER = '89390100000129506903';
|
||||
const CARDS = [
|
||||
{ name: 'Card A', iccid: DIGITS },
|
||||
{ name: 'Card B', iccid: OTHER },
|
||||
];
|
||||
|
||||
test('no warning without a card session or a readable ICCID', () => {
|
||||
assert.strictEqual(spPresetWarning(null, CARDS, 0), '');
|
||||
assert.strictEqual(spPresetWarning('', CARDS, 0), '');
|
||||
assert.strictEqual(spPresetWarning(undefined, CARDS, 0), '');
|
||||
});
|
||||
|
||||
test('no warning when the equipped card matches a preset', () => {
|
||||
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 0), '');
|
||||
// the preset's ICCID may be stored in any form
|
||||
assert.strictEqual(spPresetWarning(DIGITS, [{ name: 'A', iccid: RAW_HEX }], 0), '');
|
||||
// the match does not depend on the selection
|
||||
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 1) === '', false);
|
||||
});
|
||||
|
||||
test('a card with no matching preset gets the strong warning', () => {
|
||||
const text = spPresetWarning('8945001234567890123', CARDS, 0);
|
||||
assert.match(text, /8945001234567890123/);
|
||||
assert.match(text, /does not match any card preset/);
|
||||
assert.match(text, /From card/);
|
||||
// no presets at all: the other wording
|
||||
const none = spPresetWarning(DIGITS, [], 0);
|
||||
assert.match(none, /No card presets defined/);
|
||||
assert.match(spPresetWarning(DIGITS, null, -1), /No card presets defined/);
|
||||
});
|
||||
|
||||
test('a manually selected preset of another card gets the softer hint', () => {
|
||||
const text = spPresetWarning(DIGITS, CARDS, 1);
|
||||
assert.match(text, /selected preset is not the equipped card/);
|
||||
assert.match(text, /Card A/);
|
||||
// an empty / out-of-range selection is not the softer case (the strong
|
||||
// warning above covers a card with no preset)
|
||||
assert.strictEqual(spPresetWarning(DIGITS, CARDS, -1), '');
|
||||
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 5), '');
|
||||
});
|
||||
|
||||
test('both SCP80 views carry the warning banner', () => {
|
||||
assert.match(html, /id="sp-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/);
|
||||
assert.match(html, /id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/);
|
||||
// ... and the renderer is wired into the card state, the preset load and
|
||||
// the preset selection
|
||||
assert.ok(html.includes('spPresetWarningRender();'));
|
||||
assert.ok(html.includes("show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));"));
|
||||
// the batch resolves the preset per card: the RAM banner is suppressed there
|
||||
assert.ok(html.includes("batchMode ? '' : spPresetWarning(iccid, cards, ramPresetIdx())"));
|
||||
});
|
||||
+1758
-6
File diff suppressed because it is too large
Load Diff
+1650
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
const ASYNC_FNS = ['ramBatchFetch', 'ramBatchStart', 'ramBatchStop', 'ramBatchClear'];
|
||||
let code = '';
|
||||
for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
|
||||
'ramBatchTitle', 'ramBatchModalTitle', 'ramBatchModalOpen', 'ramBatchModalRender',
|
||||
'ramBatchStepsText',
|
||||
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
||||
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
|
||||
'ramOpModalClose', 'esc']) {
|
||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||
}
|
||||
|
||||
const RAM_BATCH_FETCH_STALE_MS = parseInt(
|
||||
/const RAM_BATCH_FETCH_STALE_MS = (\d+);/.exec(html)[1], 10);
|
||||
|
||||
const els = {};
|
||||
function setEl(id, value) {
|
||||
els[id] = {
|
||||
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
||||
disabled: false, style: {},
|
||||
classes: {},
|
||||
classList: {
|
||||
add(c) { els[id].classes[c] = true; },
|
||||
remove(c) { delete els[id].classes[c]; },
|
||||
toggle(c, on) { if (on) els[id].classes[c] = true; else delete els[id].classes[c]; },
|
||||
contains(c) { return !!els[id].classes[c]; },
|
||||
},
|
||||
};
|
||||
if (id === 'ram-batch-attempts') {
|
||||
// like a browser: replacing the content resets the scroll position
|
||||
Object.defineProperty(els[id], 'innerHTML', {
|
||||
get() { return this._html || ''; },
|
||||
set(v) { this._html = v; this.scrollTop = 0; },
|
||||
});
|
||||
}
|
||||
return els[id];
|
||||
}
|
||||
function clearEls() {
|
||||
for (const k of Object.keys(els)) delete els[k];
|
||||
}
|
||||
// The modal chrome and the batch panel: the element sets the modal tests need.
|
||||
function setModalEls() {
|
||||
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
|
||||
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
|
||||
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear']) setEl(id, '');
|
||||
}
|
||||
function setPanelEls() {
|
||||
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
|
||||
'ram-batch-clear', 'ram-batch-show', 'ram-batch-spin', 'ram-batch-state',
|
||||
'ram-batch-notice', 'ram-batch-counts', 'ram-batch-attempts']) setEl(id, '');
|
||||
els['ram-op'].value = 'batch';
|
||||
}
|
||||
|
||||
const app = new Function('els', `
|
||||
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
|
||||
var document = { getElementById: id => els[id] || null };
|
||||
var t = k => k;
|
||||
var isViewVisible = () => sandbox.visibility;
|
||||
var alert = m => sandbox.alerts.push(String(m));
|
||||
var pysimAlert = m => alert(m);
|
||||
var pysimConfirm = async () => true; // not used by the batch flow
|
||||
var pysimPrompt = async () => ''; // not used by the batch flow
|
||||
var pysimFetch = async (p) => {
|
||||
sandbox.fetched.push(p);
|
||||
if (sandbox.hangs) return new Promise(() => {}); // a stalled connection
|
||||
return sandbox.replies[p] || null;
|
||||
};
|
||||
var pysimFetchOk = async (p, body) => {
|
||||
sandbox.fetched.push(p);
|
||||
sandbox.bodies = sandbox.bodies || {};
|
||||
sandbox.bodies[p] = body;
|
||||
if (sandbox.fetchError) throw new Error(sandbox.fetchError);
|
||||
return sandbox.replies[p] || { ok: true };
|
||||
};
|
||||
var capGateOk = () => sandbox.capGate !== false;
|
||||
var ramReadFileHex = async () => 'AABB';
|
||||
var ramInstallCapFields = () => ({ sd_aid: '', install_params: '', ram_format: 'auto' });
|
||||
var ramInstallParamsCompose = () => ({ install_params: 'C900', stk_params: '' });
|
||||
var updateRcPriv = () => {};
|
||||
var updateStkParamsHex = () => {};
|
||||
var pysimApplyAvailability = () => {};
|
||||
var _ramOpActive = false;
|
||||
var _ramBatchModalShown = false;
|
||||
var _ramBatchLastKind = null;
|
||||
var _ramBatchLast = null;
|
||||
var _ramBatchFetching = false;
|
||||
var _ramBatchFetchAt = 0;
|
||||
var _ramBatchSummary = null;
|
||||
var _ramBatchProgress = null;
|
||||
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
||||
${code}
|
||||
sandbox.getLast = () => _ramBatchLast;
|
||||
sandbox.setLast = v => { _ramBatchLast = v; };
|
||||
sandbox.getSummary = () => _ramBatchSummary;
|
||||
sandbox.setSummary = v => { _ramBatchSummary = v; };
|
||||
sandbox.isFetching = () => _ramBatchFetching;
|
||||
sandbox.setFetchingStale = ms => { _ramBatchFetching = true; _ramBatchFetchAt = Date.now() - ms; };
|
||||
sandbox.resetFetching = () => { _ramBatchFetching = false; };
|
||||
sandbox.getProgress = () => _ramBatchProgress;
|
||||
sandbox.ramBatchPanelVisible = ramBatchPanelVisible;
|
||||
sandbox.ramBatchAttemptsHtml = ramBatchAttemptsHtml;
|
||||
sandbox.ramBatchStepsText = ramBatchStepsText;
|
||||
sandbox.ramBatchModalOpen = ramBatchModalOpen;
|
||||
sandbox.ramOpModalClose = ramOpModalClose;
|
||||
sandbox.isBatchModalShown = () => _ramBatchModalShown;
|
||||
sandbox.resetBatchModal = () => { _ramBatchModalShown = false; };
|
||||
sandbox.resetKind = () => { _ramBatchLastKind = null; };
|
||||
sandbox.setProgress = v => { _ramBatchProgress = v; };
|
||||
sandbox.ramBatchRender = ramBatchRender;
|
||||
sandbox.ramBatchApplyStatus = ramBatchApplyStatus;
|
||||
sandbox.ramBatchFetch = ramBatchFetch;
|
||||
sandbox.ramBatchStart = ramBatchStart;
|
||||
sandbox.ramBatchStop = ramBatchStop;
|
||||
sandbox.ramBatchClear = ramBatchClear;
|
||||
return sandbox;
|
||||
`)(els);
|
||||
|
||||
test('the RAM view wires the batch operation and the panel', () => {
|
||||
assert.match(html, /<option value="batch" data-l10n="Batch install \(\.cap file, per card\)">/);
|
||||
assert.match(html, /<div id="ram-batch-panel"/);
|
||||
for (const id of ['ram-batch-start', 'ram-batch-stop', 'ram-batch-clear',
|
||||
'ram-batch-state', 'ram-batch-notice', 'ram-batch-counts', 'ram-batch-attempts']) {
|
||||
assert.match(html, new RegExp('id="' + id + '"'));
|
||||
}
|
||||
// the batch start needs the server (not a card) and the CAP analysis
|
||||
assert.match(html, /id="ram-batch-start" data-needs="server" data-cap-gate="ram"/);
|
||||
assert.match(html, /'Batch install \(\.cap file, per card\)': /);
|
||||
});
|
||||
|
||||
test('ramOpChanged hides the preset cell and Execute in batch mode', () => {
|
||||
const src = extractFunc(html, 'ramOpChanged');
|
||||
assert.match(src, /const isBatch = ramIsBatchOp\(op\);/);
|
||||
// the per-card batch set lives in one place (the panel and the preset
|
||||
// warning follow it too)
|
||||
const helper = extractFunc(html, 'ramIsBatchOp');
|
||||
assert.match(helper, /op === 'batch' \|\| op === 'batch-reinstall' \|\| op === 'batch-delete'/);
|
||||
assert.match(extractFunc(html, 'ramBatchPanelVisible'), /ramIsBatchOp\(op\.value\)/);
|
||||
assert.match(extractFunc(html, 'spPresetWarningRender'), /ramIsBatchOp\(ramOp\.value\)/);
|
||||
assert.match(src, /const isCap = op === 'install-cap' \|\| op === 'batch';/);
|
||||
assert.match(src, /const isDelete = op === 'batch-delete';/);
|
||||
assert.match(src, /setHidden\('ram-preset-cell', isBatch\)/);
|
||||
assert.match(src, /setHidden\('ram-exec-btn', isBatch\)/);
|
||||
assert.match(src, /setHidden\('ram-batch-panel', !isBatch\)/);
|
||||
assert.match(src, /setHidden\('ram-delete-rows', !isDelete\)/);
|
||||
assert.match(src, /setHidden\('ram-del-package-row', !isCap\)/);
|
||||
assert.match(src, /if \(isBatch\) ramBatchFetch\(\);/);
|
||||
// the preset warning does not apply to the per-card resolution
|
||||
const warn = extractFunc(html, 'spPresetWarningRender');
|
||||
assert.match(warn, /batchMode \? '' : spPresetWarning/);
|
||||
});
|
||||
|
||||
test('the status poll drives the batch panel', () => {
|
||||
const poll = extractFunc(html, 'pysimStartBackendPoll');
|
||||
assert.match(poll, /const status = await pysimFetch\('\/api\/status'\);/);
|
||||
assert.match(poll, /ramBatchApplyStatus\(status\);/);
|
||||
// the single install and the batch share the field builder
|
||||
const inst = extractFunc(html, 'ramInstallCap', true);
|
||||
assert.match(inst, /const fields = ramInstallCapFields\(\);/);
|
||||
assert.match(inst, /Object\.assign\(\{/);
|
||||
const start = extractFunc(html, 'ramBatchStart', true);
|
||||
assert.match(start, /ramInstallCapFields\(\)/);
|
||||
assert.match(start, /pysimFetchOk\('\/api\/ram-batch\/start', fields\)/);
|
||||
});
|
||||
|
||||
test('ramBatchAttemptsHtml renders every verdict with its detail', () => {
|
||||
const out = app.ramBatchAttemptsHtml([
|
||||
{ ts: 1700000000, iccid: '8970119000004600098', preset: 'Alfa', result: 'installed', duration_ms: 12000 },
|
||||
{ ts: 1700000001, iccid: '8970119000004600106', preset: 'Alfa', result: 'failed',
|
||||
failed_step: 'LOAD (3/7)', por_sw: '6A80', duration_ms: 9000 },
|
||||
{ ts: 1700000002, iccid: '8970119000004600114', result: 'skipped',
|
||||
reason: 'no preset matches this ICCID' },
|
||||
]);
|
||||
assert.match(out, /8970119000004600098/);
|
||||
assert.match(out, /OK/);
|
||||
assert.match(out, /LOAD \(3\/7\) — 6A80/);
|
||||
assert.match(out, /no preset matches this ICCID/);
|
||||
assert.match(out, /12\.0 s/);
|
||||
// newest first
|
||||
assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098'));
|
||||
assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./);
|
||||
// a failure without a step (a lost card mid-chain) shows its error
|
||||
assert.match(app.ramBatchAttemptsHtml([{ iccid: '8970119000004600122', result: 'failed',
|
||||
error: 'card removed' }]), /card removed/);
|
||||
});
|
||||
|
||||
test('the install and the batch send the form\'s ram_format', () => {
|
||||
const fields = extractFunc(html, 'ramInstallCapFields');
|
||||
assert.match(fields, /ram_format: \(document\.getElementById\('ram-format'\) \|\| \{\}\)\.value \|\| 'auto'/);
|
||||
// the single install merges the shared fields into its request body
|
||||
assert.match(extractFunc(html, 'ramInstallCap', true), /Object\.assign\(\{/);
|
||||
assert.match(extractFunc(html, 'ramBatchStart', true),
|
||||
/pysimFetchOk\('\/api\/ram-batch\/start', fields\)/);
|
||||
});
|
||||
|
||||
test('ramBatchRender shows the phase, the counts and the stop button', () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
setEl('ram-batch-clear', '');
|
||||
setEl('ram-batch-spin', '');
|
||||
// waiting for a card: the explicit prompt, no throbber
|
||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
|
||||
app.ramBatchRender();
|
||||
assert.strictEqual(els['ram-batch-state'].textContent, 'Insert next card. Waiting for a card...');
|
||||
assert.ok(els['ram-batch-spin'].classes['hidden']);
|
||||
assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined);
|
||||
assert.ok(els['ram-batch-start'].classes['hidden']);
|
||||
assert.ok(els['ram-batch-clear'].disabled); // the server refuses a clear while active
|
||||
// installing card X with the live step
|
||||
app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 },
|
||||
attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } });
|
||||
app.ramBatchRender();
|
||||
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
|
||||
assert.ok(els['ram-batch-spin'].classes['hidden'] === undefined); // the install throbber
|
||||
assert.match(els['ram-batch-counts'].textContent, /Installed: 2 · Failed: 1 · Skipped: 0 · 3 attempts/);
|
||||
assert.strictEqual(els['ram-batch-notice'].textContent, 'Card 897… installed.');
|
||||
// a new install clears the previous notice: an empty one hides the box
|
||||
app.setLast({ active: true, phase: 'installing', attempts: [], counts: {}, attempts_total: 3,
|
||||
notice: '', current: { iccid: '8970119000004600106' } });
|
||||
app.ramBatchRender();
|
||||
assert.ok(els['ram-batch-notice'].classes['hidden']);
|
||||
// stopping while a card is still being installed: the throbber stays
|
||||
app.setLast({ active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0,
|
||||
current: { iccid: '8970119000004600098' } });
|
||||
app.ramBatchRender();
|
||||
assert.ok(els['ram-batch-spin'].classes['hidden'] === undefined);
|
||||
assert.match(els['ram-batch-state'].textContent, /Stopping after the current card\.\.\./);
|
||||
// ...but not once the install finished (stopping while waiting)
|
||||
app.setLast({ active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0, current: null });
|
||||
app.ramBatchRender();
|
||||
assert.ok(els['ram-batch-spin'].classes['hidden']);
|
||||
assert.strictEqual(els['ram-batch-stop'].textContent, 'Stopping...');
|
||||
assert.ok(els['ram-batch-stop'].disabled);
|
||||
// stopped
|
||||
app.setLast({ active: false, phase: 'stopped', attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchRender();
|
||||
assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped');
|
||||
assert.ok(els['ram-batch-start'].classes['hidden'] === undefined);
|
||||
assert.ok(els['ram-batch-stop'].classes['hidden']);
|
||||
assert.ok(!els['ram-batch-clear'].disabled); // clearable again
|
||||
});
|
||||
|
||||
test('ramBatchApplyStatus fetches only when the summary changed', async () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
app.fetched = [];
|
||||
app.replies['/api/ram-batch/status'] = { active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 1 };
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1, installed: 0, failed: 0, skipped: 0 } });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/status']);
|
||||
// an unchanged summary does not refetch
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1 } });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(app.fetched.length, 1);
|
||||
// a new attempt refetches
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 2 } });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(app.fetched.length, 2);
|
||||
// the panel is not visible -> no fetch, no render
|
||||
clearEls();
|
||||
app.fetched = [];
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, attempts_total: 5 } });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, []);
|
||||
});
|
||||
|
||||
test('the panel headline follows the live summary (phase and step)', () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
// the detailed status is stale (waiting), the 2 s summary says installing
|
||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchApplyStatus({
|
||||
ram_batch: { active: true, phase: 'installing', attempts_total: 0,
|
||||
current: { iccid: '8970119000004600098', preset: 'Alfa' },
|
||||
counts: { installed: 0, failed: 0, skipped: 0 } },
|
||||
ram_progress: { active: true, step: 3, total: 8, name: 'LOAD (3/7)' },
|
||||
});
|
||||
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
|
||||
assert.match(els['ram-batch-counts'].textContent, /LOAD \(3\/7\)/);
|
||||
// ...and back to waiting when the card is gone
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 0,
|
||||
counts: { installed: 0, failed: 0, skipped: 0 } } });
|
||||
assert.strictEqual(els['ram-batch-state'].textContent, 'Insert next card. Waiting for a card...');
|
||||
});
|
||||
|
||||
test('a hung detailed fetch does not freeze the counts (v3.28.7)', async () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-clear', '');
|
||||
setEl('ram-batch-spin', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
app.hangs = true; // every detailed fetch stalls
|
||||
const sum = n => ({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: n,
|
||||
counts: { installed: n, failed: 0, skipped: 0 } } });
|
||||
app.ramBatchApplyStatus(sum(12));
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.match(els['ram-batch-counts'].textContent, /Installed: 12/);
|
||||
// the fetch never settles, but the headline must follow the summary
|
||||
app.ramBatchApplyStatus(sum(31));
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.match(els['ram-batch-counts'].textContent, /Installed: 31/);
|
||||
app.hangs = false;
|
||||
});
|
||||
|
||||
test('the attempts table keeps its scroll position across a poll render (v3.28.8)', () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-clear', '');
|
||||
setEl('ram-batch-spin', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [
|
||||
{ ts: 1, iccid: '8970119000004600098', preset: 'p', result: 'installed', duration_ms: 1 }],
|
||||
counts: { installed: 1, failed: 0, skipped: 0 }, attempts_total: 1 });
|
||||
app.ramBatchRender();
|
||||
els['ram-batch-attempts'].scrollTop = 120; // the reader scrolled back
|
||||
app.ramBatchRender(); // the next 2 s poll render
|
||||
assert.strictEqual(els['ram-batch-attempts'].scrollTop, 120);
|
||||
});
|
||||
|
||||
test('a stale fetch guard is released so the report recovers (v3.28.7)', async () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-clear', '');
|
||||
setEl('ram-batch-spin', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
app.fetched = [];
|
||||
app.replies['/api/ram-batch/status'] = { active: true, phase: 'waiting_card', attempts: [],
|
||||
attempts_total: 31, counts: { installed: 31, failed: 0, skipped: 0 } };
|
||||
// the guard stuck for 11 s: the poll releases it and retries
|
||||
app.setFetchingStale(11000);
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 31,
|
||||
counts: { installed: 31, failed: 0, skipped: 0 } } });
|
||||
// the stale guard is released and this poll retries (the fetch is issued)
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/status']);
|
||||
assert.ok(!app.isFetching(), 'the guard must be clear again after the fetch');
|
||||
assert.match(els['ram-batch-counts'].textContent, /Installed: 31/);
|
||||
// a fresh guard is kept (no duplicate fetch)
|
||||
app.fetched = [];
|
||||
app.setFetchingStale(1000);
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 31,
|
||||
counts: { installed: 31, failed: 0, skipped: 0 } } });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, []);
|
||||
app.resetFetching();
|
||||
});
|
||||
|
||||
test('the batch group carries the three operations (v3.29.0)', () => {
|
||||
assert.match(html, /<optgroup label="Batch \(per card\)" data-l10n="Batch \(per card\)">/);
|
||||
assert.match(html, /<option value="batch" data-l10n="Batch install \(\.cap file, per card\)">/);
|
||||
assert.match(html, /<option value="batch-reinstall" data-l10n="Batch reinstall app \(per card\)">/);
|
||||
assert.match(html, /<option value="batch-delete" data-l10n="Batch delete \(per card\)">/);
|
||||
// the group sits at the end of the operation list
|
||||
const sel = /<select id="ram-op"[\s\S]*?<\/select>/.exec(html)[0];
|
||||
assert.ok(sel.indexOf('value="batch-delete"') > sel.indexOf('value="make-selectable"'));
|
||||
// the batch delete fields and the delete-package option exist
|
||||
assert.match(html, /id="ram-del-aid"/);
|
||||
assert.match(html, /id="ram-del-target"/);
|
||||
assert.match(html, /id="ram-del-package"/);
|
||||
assert.match(html, /'Batch reinstall app \(per card\)': /);
|
||||
assert.match(html, /'Batch delete \(per card\)': /);
|
||||
assert.match(html, /'Deleted': /);
|
||||
assert.match(html, /'Reinstalled': /);
|
||||
});
|
||||
|
||||
test('ramBatchStart sends the kind-specific body (v3.29.0)', async () => {
|
||||
// the delete kind: the AID and the target
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch-delete');
|
||||
setEl('ram-del-aid', 'f0414c4641610101');
|
||||
setEl('ram-del-target', 'package');
|
||||
app.fetched = [];
|
||||
await app.ramBatchStart();
|
||||
assert.deepStrictEqual(app.bodies['/api/ram-batch/start'],
|
||||
{ kind: 'delete', aid: 'F0414C4641610101', target: 'package', ram_format: 'auto' });
|
||||
// a short/odd AID is refused before anything is sent
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch-delete');
|
||||
setEl('ram-del-aid', 'F041');
|
||||
setEl('ram-del-target', 'app');
|
||||
app.fetched = [];
|
||||
app.alerts = [];
|
||||
await app.ramBatchStart();
|
||||
assert.deepStrictEqual(app.fetched, []);
|
||||
assert.match(app.alerts.join(' '), /5-16 bytes/);
|
||||
// the reinstall kind: the install-app fields, no .cap
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch-reinstall');
|
||||
setEl('ram-app-loadfile', 'f0414c46416101');
|
||||
setEl('ram-app-module', 'f0414c4641610101');
|
||||
setEl('ram-app-instance', '');
|
||||
setEl('rc-priv', '00');
|
||||
setEl('rc-toolkit-enable', '');
|
||||
setEl('ram-make-sel', '');
|
||||
app.fetched = [];
|
||||
await app.ramBatchStart();
|
||||
const body = app.bodies['/api/ram-batch/start'];
|
||||
assert.strictEqual(body.kind, 'reinstall-app');
|
||||
assert.strictEqual(body.loadfile_aid, 'F0414C46416101');
|
||||
assert.strictEqual(body.module_aid, 'F0414C4641610101');
|
||||
assert.strictEqual(body.install_params, 'C900');
|
||||
// the cap kind carries the delete-package flag (the shared field builder)
|
||||
assert.match(extractFunc(html, 'ramInstallCapFields'),
|
||||
/delete_package: !!\(document\.getElementById\('ram-del-package'\) \|\| \{\}\)\.checked/);
|
||||
});
|
||||
|
||||
test('the batch panel labels follow the run kind (v3.29.0)', () => {
|
||||
clearEls();
|
||||
setEl('ram-op', 'batch-delete');
|
||||
setEl('ram-batch-panel', '');
|
||||
setEl('ram-batch-start', '');
|
||||
setEl('ram-batch-stop', '');
|
||||
setEl('ram-batch-state', '');
|
||||
setEl('ram-batch-notice', '');
|
||||
setEl('ram-batch-counts', '');
|
||||
setEl('ram-batch-attempts', '');
|
||||
setEl('ram-batch-clear', '');
|
||||
setEl('ram-batch-spin', '');
|
||||
app.setLast({ kind: 'delete', active: true, phase: 'installing', attempts: [],
|
||||
counts: { installed: 2, failed: 0, skipped: 0 }, attempts_total: 2,
|
||||
current: { iccid: '8970119000004600098' } });
|
||||
app.ramBatchRender();
|
||||
assert.match(els['ram-batch-state'].textContent, /Deleting\.\.\. 8970119000004600098/);
|
||||
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
|
||||
app.setLast({ kind: 'reinstall-app', active: true, phase: 'waiting_card',
|
||||
attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchRender();
|
||||
assert.match(els['ram-batch-counts'].textContent, /Reinstalled: 0/);
|
||||
});
|
||||
|
||||
test('the attempts render the per-step tail for short chains (v3.29.0)', () => {
|
||||
const out = app.ramBatchAttemptsHtml([
|
||||
{ iccid: '8970119000004600098', result: 'installed',
|
||||
steps: [{ name: 'DELETE (application)', sw: '6101', ok: true },
|
||||
{ name: 'INSTALL [for install]', sw: '6101', ok: true }] },
|
||||
{ iccid: '8970119000004600106', result: 'installed',
|
||||
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true },
|
||||
{ name: 'INSTALL [for install]', sw: '6101', ok: true }] },
|
||||
]);
|
||||
assert.match(out, /DELETE \(application\) 6101 · INSTALL \[for install\] 6101/);
|
||||
assert.match(out, /DELETE \(application\) 6A88 \(not found\)/);
|
||||
// a long LOAD chain keeps no tail (the failing-step detail covers it)
|
||||
assert.strictEqual(app.ramBatchStepsText({ steps: [{}, {}, {}, {}], reason: '' }), '');
|
||||
});
|
||||
|
||||
test('the batch status modal opens on Start and on a poll with a run in flight (v3.30.0)', async () => {
|
||||
// the markup: the window's Stop/Clear/info and the panel's Show status
|
||||
assert.match(html, /id="ram-op-batch-stop"/);
|
||||
assert.match(html, /id="ram-op-batch-clear"/);
|
||||
assert.match(html, /id="ram-op-batch-info"/);
|
||||
assert.match(html, /id="ram-batch-show" onclick="ramBatchModalOpen\(\)"/);
|
||||
assert.match(html, /'Show status': /);
|
||||
// Start opens it
|
||||
clearEls();
|
||||
setModalEls();
|
||||
setPanelEls();
|
||||
app.setSummary(null);
|
||||
els['ram-op'].value = 'batch-delete';
|
||||
setEl('ram-del-aid', 'F0414C4641610101');
|
||||
setEl('ram-del-target', 'app');
|
||||
app.fetched = [];
|
||||
await app.ramBatchStart();
|
||||
assert.ok(app.isBatchModalShown());
|
||||
assert.ok(els['ram-op-modal'].classes['hidden'] === undefined);
|
||||
// a page load with an active run reopens it on the first poll
|
||||
clearEls();
|
||||
setModalEls();
|
||||
setPanelEls();
|
||||
app.resetBatchModal();
|
||||
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card',
|
||||
attempts_total: 0, counts: { installed: 0, failed: 0, skipped: 0 } },
|
||||
ram_progress: null });
|
||||
assert.ok(app.isBatchModalShown());
|
||||
assert.ok(els['ram-op-modal'].classes['hidden'] === undefined);
|
||||
});
|
||||
|
||||
test('the batch modal follows the run: the step, the bar, the cards (v3.30.0)', () => {
|
||||
clearEls();
|
||||
setModalEls();
|
||||
setPanelEls();
|
||||
app.setSummary(null);
|
||||
app.ramBatchModalOpen();
|
||||
// waiting for a card
|
||||
app.setLast({ kind: 'reinstall-app', active: true, phase: 'waiting_card',
|
||||
attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchRender();
|
||||
assert.strictEqual(els['ram-op-title'].textContent, 'Batch reinstall app (per card)');
|
||||
assert.strictEqual(els['ram-op-status'].textContent, 'Insert next card. Waiting for a card...');
|
||||
assert.strictEqual(els['ram-op-bar'].style.width, '0%');
|
||||
assert.ok(els['ram-op-close'].disabled);
|
||||
assert.ok(els['ram-op-batch-stop'].classes['hidden'] === undefined);
|
||||
assert.ok(els['ram-op-batch-clear'].disabled);
|
||||
// installing: the live step and the bar
|
||||
app.setLast({ kind: 'reinstall-app', active: true, phase: 'installing',
|
||||
attempts: [], counts: { installed: 1, failed: 0, skipped: 0 },
|
||||
attempts_total: 1, current: { iccid: '8970119000004600098' } });
|
||||
app.setProgress({ active: true, step: 2, total: 4, name: 'INSTALL [for install]' });
|
||||
app.ramBatchRender();
|
||||
assert.match(els['ram-op-status'].textContent, /Step 2\/4 — INSTALL \[for install\]/);
|
||||
assert.strictEqual(els['ram-op-bar'].style.width, '50%');
|
||||
assert.match(els['ram-op-batch-info'].textContent, /Reinstalled: 1/);
|
||||
// the finished report: the cards list, the notice, Close enabled
|
||||
app.setLast({ kind: 'reinstall-app', active: false, phase: 'finished', attempts: [
|
||||
{ iccid: '8970119000004600098', result: 'installed', steps: [
|
||||
{ name: 'DELETE (application)', sw: '6101', ok: true },
|
||||
{ name: 'INSTALL [for install]', sw: '6101', ok: true }] }],
|
||||
counts: { installed: 1, failed: 0, skipped: 0 }, attempts_total: 1,
|
||||
notice: 'Card 8970119000004600098 reinstalled.' });
|
||||
app.setProgress(null);
|
||||
app.ramBatchRender();
|
||||
assert.match(els['ram-op-steps'].innerHTML, /DELETE \(application\) 6101 · INSTALL \[for install\] 6101/);
|
||||
assert.match(els['ram-op-batch-info'].textContent, /Card 8970119000004600098 reinstalled\./);
|
||||
assert.strictEqual(els['ram-op-bar'].style.width, '100%');
|
||||
assert.ok(!els['ram-op-close'].disabled);
|
||||
assert.ok(els['ram-op-batch-stop'].classes['hidden']); // the run ended
|
||||
assert.ok(!els['ram-op-batch-clear'].disabled);
|
||||
});
|
||||
|
||||
test('a cleared report keeps the run kind in the modal title (v3.30.1)', () => {
|
||||
clearEls();
|
||||
setModalEls();
|
||||
setPanelEls();
|
||||
app.setSummary(null);
|
||||
// the state is seeded before the window opens: no reliance on the sandbox
|
||||
// leftovers of the previous test (the modal opens into the run's kind)
|
||||
app.setLast({ kind: 'delete', active: false, phase: 'finished', attempts: [],
|
||||
counts: { installed: 2 }, attempts_total: 2 });
|
||||
app.ramBatchModalOpen();
|
||||
assert.strictEqual(els['ram-op-title'].textContent, 'Batch delete (per card)');
|
||||
// Clear empties the state (kind: null): the title must not flip to install
|
||||
app.setLast({ kind: null, active: false, phase: 'idle', attempts: [],
|
||||
counts: {}, attempts_total: 0 });
|
||||
app.ramBatchRender();
|
||||
assert.strictEqual(els['ram-op-title'].textContent, 'Batch delete (per card)');
|
||||
// a fresh page (no kind ever seen) keeps the install default
|
||||
app.resetBatchModal();
|
||||
app.resetKind();
|
||||
app.setLast({ kind: null, active: false, phase: 'idle', attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchModalOpen();
|
||||
assert.strictEqual(els['ram-op-title'].textContent, 'Batch install (.cap file, per card)');
|
||||
});
|
||||
|
||||
test('the batch modal stays open while the run is active (v3.30.0)', () => {
|
||||
clearEls();
|
||||
setModalEls();
|
||||
setPanelEls();
|
||||
app.setSummary(null);
|
||||
app.ramBatchModalOpen();
|
||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0 });
|
||||
app.ramBatchRender();
|
||||
app.ramOpModalClose();
|
||||
assert.ok(app.isBatchModalShown(), 'the run keeps the window open');
|
||||
assert.ok(els['ram-op-modal'].classes['hidden'] === undefined);
|
||||
// the run ended: Close works, and the panel offers the reopen button
|
||||
app.setLast({ active: false, phase: 'stopped', attempts: [], counts: { installed: 1 },
|
||||
attempts_total: 1 });
|
||||
app.ramBatchRender();
|
||||
app.ramOpModalClose();
|
||||
assert.ok(!app.isBatchModalShown());
|
||||
assert.ok(els['ram-op-modal'].classes['hidden']);
|
||||
app.ramBatchRender();
|
||||
assert.ok(els['ram-batch-show'].classes['hidden'] === undefined);
|
||||
// and it reopens for the finished report
|
||||
app.ramBatchModalOpen();
|
||||
assert.ok(app.isBatchModalShown());
|
||||
assert.ok(els['ram-op-modal'].classes['hidden'] === undefined);
|
||||
});
|
||||
|
||||
test('the stop and clear calls hit their endpoints', async () => {
|
||||
clearEls();
|
||||
app.fetched = [];
|
||||
app.replies['/api/ram-batch/stop'] = { active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0 };
|
||||
app.replies['/api/ram-batch/clear'] = { ok: true };
|
||||
app.replies['/api/ram-batch/status'] = { active: false, phase: 'idle', attempts: [], counts: {}, attempts_total: 0 };
|
||||
setEl('ram-op', 'batch');
|
||||
setEl('ram-batch-panel', '');
|
||||
await app.ramBatchStop();
|
||||
await app.ramBatchClear();
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// The real functions behind the Explore Delete flow: the counter the card
|
||||
// consumed must be persisted into the preset, a successful delete drops the
|
||||
// record locally (no re-explore), and a delete with no PoR (the SPI may
|
||||
// request none) is not a failure - live 2026-09-28 showed "Failed: 9000"
|
||||
// although the delete executed.
|
||||
let code = '';
|
||||
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
|
||||
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
|
||||
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
|
||||
'ramHideProgress', 'ramDeleteFromExplorer',
|
||||
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
|
||||
'ramCntrLowBox', 'ramCntrLowClear',
|
||||
'escHtml', 'esc',
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsRoleTar', 'cardsTarEntry', 'cardsTarList', 'cardsRoleDefault', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
|
||||
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
|
||||
'ramPresetIdx', 'spKeysetGuard',
|
||||
'ramKeysetGuard',
|
||||
]) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code = 'var _cardsKeysetCount = 0;\n' + code;
|
||||
// the role defaults the TAR helpers read (v3.26.0)
|
||||
for (const c of ['CARDS_TAR_DEFAULTS', 'CARDS_TAR_ROLES', 'CARDS_MSL_DEFAULT']) {
|
||||
const m = new RegExp('const\\s+' + c + '\\s*=\\s*([^;]+);').exec(html);
|
||||
if (!m) throw new Error('const ' + c + ' not found');
|
||||
code = 'var ' + c + ' = ' + m[1] + ';\n' + code;
|
||||
}
|
||||
eval(code);
|
||||
// The dialog sweep replaced the native alert/confirm with the helpers: route
|
||||
// them to the native stubs fakeEnv() installs on globalThis.
|
||||
globalThis.pysimAlert = m => globalThis.alert(m);
|
||||
globalThis.pysimConfirm = async m => globalThis.confirm(m);
|
||||
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
|
||||
|
||||
function fakeEnv(por, success) {
|
||||
const els = {};
|
||||
const mk = () => ({ value: '', textContent: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} } });
|
||||
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
|
||||
'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
|
||||
'ram-progress', 'ram-progress-text']) els[id] = mk();
|
||||
els['ram-card-sel'] = { value: '0' };
|
||||
// the preset keys the real spRefreshFromPreset/cardsApply would copy into
|
||||
// the form (the delete flow aborts without them)
|
||||
els['sp-kic-key'].value = 'AA';
|
||||
els['sp-kid-key'].value = 'BB';
|
||||
els['sp-spi2-hex'].value = '01';
|
||||
els['sp-kic-hex'].value = '15';
|
||||
els['sp-kid-hex'].value = '15';
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
|
||||
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
|
||||
const calls = { saved: 0, explored: null, removed: null, sent: null };
|
||||
globalThis.cardsSave = () => { calls.saved++; };
|
||||
globalThis.cardsRender = () => {};
|
||||
globalThis.ramRender = () => {};
|
||||
globalThis.t = s => s;
|
||||
globalThis.alert = () => {};
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.spRefreshFromPreset = () => {
|
||||
const cntr = cards[0].keysets[0].cntr;
|
||||
els['sp-cntr'].value = cntr;
|
||||
return cntr;
|
||||
};
|
||||
globalThis.ramSendOta = async (apdu, sp) => {
|
||||
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
|
||||
return { success: success !== false, por: por };
|
||||
};
|
||||
globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
|
||||
globalThis.ramRemoveFromExplorer = (aid, cascade) => {
|
||||
calls.removed = { aid: aid, cascade: cascade };
|
||||
return true;
|
||||
};
|
||||
return { els, calls };
|
||||
}
|
||||
|
||||
test('accepted delete persists the consumed counter and drops the record', async () => {
|
||||
const { els, calls } = fakeEnv({ response_status: 'por_ok',
|
||||
decoded: { last_status_word: '9000' } });
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(calls.sent.cntr, '0000000005');
|
||||
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
|
||||
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
|
||||
'the preset must carry the counter the card consumed');
|
||||
// the form field is an override (empty = auto, the server seeds it); the
|
||||
// operation only syncs the local preset copy, never the field (v3.20.0)
|
||||
assert.strictEqual(els['sp-cntr'].value, '0000000005');
|
||||
assert.strictEqual(calls.sent.preset_id, 'preset-1',
|
||||
'the operation must name the server-side preset that persists the counter');
|
||||
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
||||
assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally');
|
||||
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||
});
|
||||
|
||||
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
|
||||
const { els, calls } = fakeEnv(undefined);
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
||||
assert.strictEqual(els['ram-result'].textContent, 'OK');
|
||||
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
|
||||
});
|
||||
|
||||
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'cntr_low' });
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
||||
assert.strictEqual(calls.saved, 0);
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
test('a refused DELETE still advances the counter but drops nothing', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'por_ok',
|
||||
decoded: { last_status_word: '6A88' } });
|
||||
await ramDeleteFromExplorer('F0414C46416101', true);
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
|
||||
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
|
||||
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
|
||||
});
|
||||
|
||||
test('a send failure leaves the preset untouched', async () => {
|
||||
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
|
||||
await ramDeleteFromExplorer('F0414C46416101', false);
|
||||
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
|
||||
assert.strictEqual(calls.removed, null);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['updateRcPriv']) code += extractFunc(html, fn) + '\n';
|
||||
eval(code);
|
||||
|
||||
// The install form's grants are per-session choices: the privileges are an
|
||||
// aggregate of the checkboxes, derived at send time (a browser-restored
|
||||
// Receipt Generation bit produced 6985 on the live card, 2026-09-28).
|
||||
function fakeEnv(checked) {
|
||||
const els = { 'rc-priv': { value: '' } };
|
||||
const boxes = checked.map(([cls, val]) => ({ className: cls, value: val, checked: true }));
|
||||
globalThis.document = {
|
||||
getElementById: id => els[id] || null,
|
||||
querySelectorAll: sel => boxes.filter(b => sel.indexOf('.' + b.className) >= 0),
|
||||
};
|
||||
return els;
|
||||
}
|
||||
|
||||
test('updateRcPriv aggregates the checked privilege boxes', () => {
|
||||
let els = fakeEnv([]);
|
||||
updateRcPriv();
|
||||
assert.strictEqual(els['rc-priv'].value, '00');
|
||||
els = fakeEnv([['rc-priv-b3', '80']]);
|
||||
updateRcPriv();
|
||||
assert.strictEqual(els['rc-priv'].value, '000080', 'Receipt Generation is the third byte');
|
||||
els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b3', '80']]);
|
||||
updateRcPriv();
|
||||
assert.strictEqual(els['rc-priv'].value, '800080');
|
||||
els = fakeEnv([['rc-priv-b1', '04'], ['rc-priv-b2', '80']]);
|
||||
updateRcPriv();
|
||||
assert.strictEqual(els['rc-priv'].value, '048000');
|
||||
els = fakeEnv([['rc-priv-b1', '80'], ['rc-priv-b1', '40'], ['rc-priv-b2', '80']]);
|
||||
updateRcPriv();
|
||||
assert.strictEqual(els['rc-priv'].value, 'C08000', 'bits within a byte are OR-ed');
|
||||
});
|
||||
|
||||
test('the install derives the privileges at send time', () => {
|
||||
// v3.28.0: the shared field builder (the single install and the batch) does
|
||||
// the derivation before it returns the body
|
||||
const fn = extractFunc(html, 'ramInstallCapFields');
|
||||
const iPriv = fn.indexOf('updateRcPriv();');
|
||||
const iBody = fn.indexOf('const body = {');
|
||||
assert.ok(iPriv >= 0, 'ramInstallCapFields must call updateRcPriv()');
|
||||
assert.ok(iBody >= 0, 'ramInstallCapFields body not found');
|
||||
assert.ok(iPriv < iBody, 'the privileges must be derived before the request body');
|
||||
// both callers go through it
|
||||
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramInstallCap')),
|
||||
'ramInstallCap must use the shared field builder');
|
||||
assert.ok(/ramInstallCapFields\(\)/.test(extractFunc(html, 'ramBatchStart')),
|
||||
'ramBatchStart must use the shared field builder');
|
||||
});
|
||||
|
||||
test('the form resets the restored grants on load', () => {
|
||||
assert.ok(/function ramResetGrants\(\)/.test(html), 'ramResetGrants is missing');
|
||||
assert.ok(/querySelectorAll\('\.rc-priv-b1, \.rc-priv-b2, \.rc-priv-b3'\)/.test(html),
|
||||
'the privilege checkboxes must be cleared');
|
||||
assert.ok(/chainInit\('chain-ram'\);\s*\n\s*ramResetGrants\(\);/.test(html),
|
||||
'ramResetGrants must run on load');
|
||||
assert.ok(/getElementById\('rc-toolkit-enable'\)\.checked = false;/.test(html),
|
||||
'the toolkit enable must be cleared');
|
||||
assert.ok(/getElementById\('rc-tk-fsaccess'\)\.checked = false;/.test(html),
|
||||
'the file-access grant must be cleared');
|
||||
});
|
||||
@@ -38,22 +38,19 @@ test('gp 6310 label does not reference removed P2=42 option', () => {
|
||||
});
|
||||
|
||||
test('LIFECYCLE_MAP per GPC v2.3 life cycles', () => {
|
||||
assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card) / LOADED (ELF)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE');
|
||||
assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['1F'], 'PERSONALIZED (SD)');
|
||||
// GP Card Spec v2.3.1 11.1.1 Tables 11-3..11-6: the coding depends on the
|
||||
// object kind (the ISD inherits the card life cycle), so the shared map
|
||||
// names every interpretation. The Explore decodes per kind instead
|
||||
// (ramFmtLifecycle).
|
||||
assert.strictEqual(LIFECYCLE_MAP['01'], 'OP_READY (card/ISD) / LOADED (ELF)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['03'], 'INSTALLED (app/SD)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['07'], 'SELECTABLE (app/SD) / INITIALIZED (card/ISD)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['0F'], 'SECURED (card/ISD) / PERSONALIZED (SD)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['1F'], 'App-specific (app)');
|
||||
assert.strictEqual(LIFECYCLE_MAP['7F'], 'CARD_LOCKED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['FF'], 'TERMINATED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED (SD)');
|
||||
});
|
||||
|
||||
test('PRIVILEGE_NAMES matches GPC v2.3 Tables 11-7/11-8/11-9', () => {
|
||||
assert.strictEqual(PRIVILEGE_NAMES[0][7], 'Mandated DAP Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][2], 'Token Verification');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[1][7], 'Global Service');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][0], 'Receipt Generation');
|
||||
assert.strictEqual(PRIVILEGE_NAMES[2][3], 'Contactless Self-Activation');
|
||||
assert.strictEqual(LIFECYCLE_MAP['83'], 'LOCKED');
|
||||
assert.strictEqual(LIFECYCLE_MAP['8F'], 'LOCKED');
|
||||
});
|
||||
|
||||
test('TS 51.011 SIM families resolve (94xx/98xx/92xx/9Exx/9Fxx)', () => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
eval(extractFunc(html, 'scp81LogLine'));
|
||||
|
||||
test('scp81LogLine renders a BIP open entry', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 4, kind: 'open', channel: 1, requested: '77.221.153.19:10174', target: '127.0.0.1:8443' }),
|
||||
'4 open ch1 77.221.153.19:10174 -> 127.0.0.1:8443');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders a TLS request with the GP headers', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 5, kind: 'tls-request', method: 'POST', uri: '/server/adminagent?cmd=1', agent: '0123456789', bytes: 0 }),
|
||||
'5 tls-request from=0123456789 POST /server/adminagent?cmd=1');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders handshake and errors', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 6, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256', identity: 'id-1' }),
|
||||
'6 tls-handshake id=id-1 PSK-AES128-CBC-SHA256');
|
||||
assert.strictEqual(scp81LogLine({ seq: 7, kind: 'tls-error', error: 'boom' }), '7 tls-error boom');
|
||||
});
|
||||
|
||||
test('scp81 log covers the dump mode kinds', () => {
|
||||
assert.strictEqual(scp81LogLine({ seq: 1, kind: 'dump-rx', bytes: 71 }), '1 dump-rx 71B');
|
||||
});
|
||||
|
||||
test('scp81LogLine renders script entries', () => {
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 9, kind: 'script-send', index: 1, apdu: '80CAFF2100' }),
|
||||
'9 script-send #1 80CAFF2100');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 12, kind: 'script-rapdu', index: 1, sw: '9000', bytes: 14 }),
|
||||
'12 script-rapdu #1 SW 9000 14B');
|
||||
assert.strictEqual(
|
||||
scp81LogLine({ seq: 13, kind: 'script-memory', applets: 4, free_nv: 61600, free_volatile: 2048 }),
|
||||
'13 script-memory applets=4 free NV=61600 free vol=2048');
|
||||
});
|
||||
|
||||
eval(html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/)[0].replace(/^const /, 'var '));
|
||||
eval(html.match(/const JC_AID_RIDS = \{[\s\S]*?\n\};/)[0].replace(/^const /, 'var '));
|
||||
eval(extractFunc(html, 'jcAidNorm'));
|
||||
eval(extractFunc(html, 'jcAidName'));
|
||||
eval(extractFunc(html, 'jcAidSuffix'));
|
||||
eval(extractFunc(html, 'scp81DecodeGetStatus'));
|
||||
eval(extractFunc(html, 'scp81GroupResults'));
|
||||
eval(extractFunc(html, 'scp81ResultLines'));
|
||||
|
||||
test('scp81DecodeGetStatus decodes complete entries', () => {
|
||||
const entries = scp81DecodeGetStatus('E32A4F08A0000000030000009F70010FC50380DE00C40BD276000005AAFFCAFE0010CC08A000000003000000');
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].aid, 'A000000003000000');
|
||||
assert.strictEqual(entries[0].lifecycle, '0F');
|
||||
assert.strictEqual(entries[0].privileges, '80DE00');
|
||||
});
|
||||
|
||||
test('scp81DecodeGetStatus reads module AIDs and skips truncated tails', () => {
|
||||
const entries = scp81DecodeGetStatus('E31B4F07A00000015153509F700101CE0201008408A000000151535041' + 'E3204F08D27600');
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].aid, 'A0000001515350');
|
||||
assert.strictEqual(entries[0].modules[0], 'A000000151535041');
|
||||
});
|
||||
|
||||
test('scp81GroupResults merges pages under one command', () => {
|
||||
const groups = scp81GroupResults({ results: [
|
||||
{ index: 4, apdu: '80F24002024F0000', sw: 'CAFE', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
|
||||
{ index: 5, apdu: '80F24002114F0F', sw: '9000', rapdu: 'E3114F08A0000000030000009F70010FC50100' },
|
||||
{ index: 1, apdu: '80CAFF2100', sw: '9000', rapdu: 'FF210B81010D8202C5D683020962' },
|
||||
] });
|
||||
assert.strictEqual(groups.length, 2);
|
||||
assert.strictEqual(groups[0].results.length, 2);
|
||||
assert.strictEqual(groups[1].key, '80CAFF');
|
||||
});
|
||||
|
||||
test('scp81ResultLines decodes the memory page', () => {
|
||||
const lines = scp81ResultLines({ apdu: '80CAFF2100', results: [
|
||||
{ rapdu: 'FF210B81010D8202C5D683020962', sw: '9000' } ] });
|
||||
assert.strictEqual(lines[0], 'applets=13 free NV=50646 B free vol=2402 B');
|
||||
});
|
||||
|
||||
test('scp81ResultLines decodes GET STATUS entries', () => {
|
||||
global.decodePrivileges = () => 'Security Domain';
|
||||
try {
|
||||
const lines = scp81ResultLines({ apdu: '80F24002024F0000', results: [
|
||||
{ rapdu: 'E3114F08A0000000030000009F70010FC50100', sw: '9000' } ] });
|
||||
assert.strictEqual(lines[0], 'A000000003000000 life=0F [Security Domain]');
|
||||
} finally {
|
||||
delete global.decodePrivileges;
|
||||
}
|
||||
});
|
||||
|
||||
test('scp81ResultLines annotates standard package AIDs and RID hints', () => {
|
||||
const lines = scp81ResultLines({ apdu: '80F24002024F0000', results: [
|
||||
{ rapdu: 'E30E4F08A0000001510000009F70010F', sw: '9000' },
|
||||
{ rapdu: 'E3174F07A00000015153509F7001018408A000000062010101', sw: '9000' },
|
||||
] });
|
||||
assert.strictEqual(lines[0], 'A000000151000000 (GlobalPlatform Issuer Security Domain) life=0F');
|
||||
assert.strictEqual(lines[1], 'A0000001515350 (GlobalPlatform RID) life=01 module=A000000062010101 (javacard.framework.service)');
|
||||
});
|
||||
|
||||
eval(extractFunc(html, 'scp81Ascii'));
|
||||
eval(extractFunc(html, 'scp81Bcd'));
|
||||
eval(extractFunc(html, 'scp81DecodeAdminParams'));
|
||||
eval(extractFunc(html, 'scp81CmdLabel'));
|
||||
|
||||
test('scp81DecodeAdminParams decodes the stored 0085 answer', () => {
|
||||
const hex = '856F84248103014003820281828500B50103B902058EC70403475042BC03020582BE05215BD50502851814383937303178787878787878787878787878787802400186070001250300100089248A096C6F63616C686F73748B1438393730317878787878787878787878787878788C012F';
|
||||
const lines = scp81DecodeAdminParams(hex);
|
||||
assert.ok(lines.includes('PSK id=89701xxxxxxxxxxxxxxx KVN/KID=40/01'));
|
||||
assert.ok(lines.includes('retry counter=1 timer=00:10:00'));
|
||||
assert.ok(lines.includes('host=localhost'));
|
||||
assert.ok(lines.includes('agent=89701xxxxxxxxxxxxxxx'));
|
||||
assert.ok(lines.includes('uri=/'));
|
||||
assert.ok(lines.includes(' apn=GPB'));
|
||||
assert.ok(lines.includes(' dest=91.213.5.2'));
|
||||
});
|
||||
|
||||
test('scp81CmdLabel names the explore commands', () => {
|
||||
assert.strictEqual(scp81CmdLabel('80CAFF2100'), 'GET DATA FF21 (extended card resources)');
|
||||
assert.strictEqual(scp81CmdLabel('80F24002024F0000'), 'GET STATUS P1=40 (applications and security domains)');
|
||||
assert.strictEqual(scp81CmdLabel('80F22002024F0000'), 'GET STATUS P1=20 (executable load files)');
|
||||
assert.strictEqual(scp81CmdLabel('80F21002024F0000'), 'GET STATUS P1=10 (executable load files and modules)');
|
||||
assert.strictEqual(scp81CmdLabel('80E8800000'), 'LOAD');
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
|
||||
for (const fn of ['scp81OptionsState', 'scp81OptionsFromForm', 'scp81OptionsSyncDisabled',
|
||||
'scp81OptionsBadge', 'scp81OptionsLoad', 'scp81OptionsPersist', 'scp81OptionsReset',
|
||||
'scp81NextUriToggle', 'scp81TargetedAppToggle']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'globalThis.t = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup(opts) {
|
||||
const spec = Object.assign({
|
||||
'opt-chunked': true,
|
||||
'opt-chunk-size': 0,
|
||||
'opt-conn-header': 'none',
|
||||
'opt-compact': false,
|
||||
'opt-next-uri': true,
|
||||
'opt-next-uri-value': '/api/scp81?req=%d',
|
||||
'opt-link-events': true,
|
||||
'opt-script-template': 'indefinite',
|
||||
'opt-cr-tag': false,
|
||||
'opt-targeted-app-on': false,
|
||||
'opt-targeted-app': '',
|
||||
'scp81-opts-badge': ''
|
||||
}, opts || {});
|
||||
const els = {};
|
||||
for (const [id, v] of Object.entries(spec)) {
|
||||
const checkbox = typeof v === 'boolean';
|
||||
els[id] = {
|
||||
checked: checkbox ? v : true,
|
||||
value: checkbox ? '' : String(v),
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
classList: { toggle() {} },
|
||||
};
|
||||
}
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
function fakeStorage(initial) {
|
||||
const store = Object.assign({}, initial || {});
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: k => { delete store[k]; },
|
||||
_store: store,
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test('scp81OptionsFromForm maps the reference defaults', () => {
|
||||
setup();
|
||||
assert.deepStrictEqual(scp81OptionsFromForm(), {
|
||||
chunked: true,
|
||||
chunk_size: 0,
|
||||
conn_header: 'none',
|
||||
compact_headers: false,
|
||||
next_uri: '/api/scp81?req=%d',
|
||||
link_events: true,
|
||||
script_template: 'indefinite',
|
||||
cr_tag: false,
|
||||
targeted_app: ''
|
||||
});
|
||||
});
|
||||
|
||||
test('scp81OptionsFromForm reflects a changed setup', () => {
|
||||
setup({
|
||||
'opt-chunked': false,
|
||||
'opt-chunk-size': '100',
|
||||
'opt-conn-header': 'keep-alive',
|
||||
'opt-compact': true,
|
||||
'opt-next-uri-value': '/adminserver?apdu_id=%d',
|
||||
'opt-link-events': false,
|
||||
'opt-script-template': 'definite',
|
||||
'opt-cr-tag': true,
|
||||
'opt-targeted-app-on': true,
|
||||
'opt-targeted-app': ' //aid/A000000151000000 '
|
||||
});
|
||||
assert.deepStrictEqual(scp81OptionsFromForm(), {
|
||||
chunked: false,
|
||||
chunk_size: 100,
|
||||
conn_header: 'keep-alive',
|
||||
compact_headers: true,
|
||||
next_uri: '/adminserver?apdu_id=%d',
|
||||
link_events: false,
|
||||
script_template: 'definite',
|
||||
cr_tag: true,
|
||||
targeted_app: '//aid/A000000151000000'
|
||||
});
|
||||
});
|
||||
|
||||
test('unchecked Next-URI omits the header; empty text falls back to the template', () => {
|
||||
setup({ 'opt-next-uri': false });
|
||||
assert.strictEqual(scp81OptionsFromForm().next_uri, '');
|
||||
setup({ 'opt-next-uri': true, 'opt-next-uri-value': ' ' });
|
||||
assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d');
|
||||
});
|
||||
|
||||
test('X-Admin-Targeted-Application is only sent when its checkbox is ticked', () => {
|
||||
// text left in the box with the checkbox off must never leak out
|
||||
setup({ 'opt-targeted-app-on': false, 'opt-targeted-app': '//aid/A000000151000000' });
|
||||
assert.strictEqual(scp81OptionsFromForm().targeted_app, '');
|
||||
setup({ 'opt-targeted-app-on': true, 'opt-targeted-app': ' //aid/A000000151000000 ' });
|
||||
assert.strictEqual(scp81OptionsFromForm().targeted_app, '//aid/A000000151000000');
|
||||
});
|
||||
|
||||
test('the dependent fields are disabled while their checkbox is off', () => {
|
||||
const els = setup({ 'opt-next-uri': false, 'opt-targeted-app-on': false });
|
||||
scp81OptionsSyncDisabled();
|
||||
assert.strictEqual(els['opt-next-uri-value'].disabled, true);
|
||||
assert.strictEqual(els['opt-targeted-app'].disabled, true);
|
||||
els['opt-next-uri'].checked = true;
|
||||
els['opt-targeted-app-on'].checked = true;
|
||||
scp81OptionsSyncDisabled();
|
||||
assert.strictEqual(els['opt-next-uri-value'].disabled, false);
|
||||
assert.strictEqual(els['opt-targeted-app'].disabled, false);
|
||||
});
|
||||
|
||||
test('the custom badge flags anything that differs from the defaults', () => {
|
||||
const els = setup();
|
||||
scp81OptionsBadge();
|
||||
assert.strictEqual(els['scp81-opts-badge'].textContent, '');
|
||||
setup({ 'opt-compact': true, 'scp81-opts-badge': '' });
|
||||
scp81OptionsBadge();
|
||||
assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, 'custom');
|
||||
// an empty Next-URI text is not a change (the default template applies)
|
||||
setup({ 'opt-next-uri-value': '', 'scp81-opts-badge': '' });
|
||||
scp81OptionsBadge();
|
||||
assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, '');
|
||||
});
|
||||
|
||||
test('load/persist/reset round-trip through localStorage', () => {
|
||||
const store = fakeStorage();
|
||||
setup({
|
||||
'opt-chunked': false,
|
||||
'opt-chunk-size': 100,
|
||||
'opt-conn-header': 'keep-alive',
|
||||
'opt-compact': true,
|
||||
'opt-next-uri': false,
|
||||
'opt-link-events': false,
|
||||
'opt-script-template': 'definite',
|
||||
'opt-cr-tag': true,
|
||||
'opt-targeted-app-on': true,
|
||||
'opt-targeted-app': '//aid/A000000151000000'
|
||||
});
|
||||
scp81OptionsPersist();
|
||||
assert.ok(store['simple_scp81_opts'].includes('"chunkSize":100'));
|
||||
assert.ok(store['simple_scp81_opts'].includes('"targetedAppOn":true'));
|
||||
assert.ok(!store['simple_scp81_opts'].includes('keepAlive'));
|
||||
// a reload restores the saved setup
|
||||
setup({});
|
||||
scp81OptionsLoad();
|
||||
const get = id => globalThis.document.getElementById(id);
|
||||
assert.strictEqual(get('opt-chunked').checked, false);
|
||||
assert.strictEqual(get('opt-chunk-size').value, 100);
|
||||
assert.strictEqual(get('opt-conn-header').value, 'keep-alive');
|
||||
assert.strictEqual(get('opt-next-uri').checked, false);
|
||||
assert.strictEqual(get('opt-script-template').value, 'definite');
|
||||
assert.strictEqual(get('opt-cr-tag').checked, true);
|
||||
assert.strictEqual(get('opt-targeted-app-on').checked, true);
|
||||
assert.strictEqual(get('opt-targeted-app').value, '//aid/A000000151000000');
|
||||
// reset clears the saved entry and restores the defaults
|
||||
scp81OptionsReset();
|
||||
assert.ok(!('simple_scp81_opts' in store));
|
||||
scp81OptionsLoad();
|
||||
assert.strictEqual(get('opt-chunked').checked, true);
|
||||
assert.strictEqual(get('opt-next-uri').checked, true);
|
||||
assert.strictEqual(get('opt-conn-header').value, 'none');
|
||||
assert.strictEqual(get('opt-targeted-app-on').checked, false);
|
||||
});
|
||||
|
||||
test('the Listener UI wires the framing options into Start', () => {
|
||||
for (const id of ['scp81-opts', 'scp81-opts-badge', 'scp81-opts-http', 'scp81-opts-script',
|
||||
'opt-chunked', 'opt-chunk-size', 'opt-conn-header', 'opt-compact',
|
||||
'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag',
|
||||
'opt-targeted-app-on', 'opt-targeted-app', 'opt-link-events']) {
|
||||
assert.ok(html.includes('id="' + id + '"'), id);
|
||||
}
|
||||
// collapsed by default: a <details> without the open attribute
|
||||
assert.match(html, /<details id="scp81-opts" class="[^"]*">/);
|
||||
assert.ok(!/<details id="scp81-opts"[^>]*\sopen/.test(html));
|
||||
// the removed switch must not come back
|
||||
assert.ok(!html.includes('opt-keep-alive'));
|
||||
// the options travel in the start body
|
||||
assert.ok(html.includes('body.chunk_size = opts.chunk_size;'));
|
||||
assert.ok(html.includes('body.next_uri = opts.next_uri;'));
|
||||
assert.ok(html.includes('body.targeted_app = opts.targeted_app || null;'));
|
||||
assert.ok(html.includes('body.script_template = opts.script_template;'));
|
||||
assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')"));
|
||||
assert.ok(html.includes("scriptOpts.classList.toggle('hidden', mode !== 'tls')"));
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// The extracted functions run in this module's scope; their free variables
|
||||
// (`cards`, `t`, ...) resolve to globals we stub here.
|
||||
global.t = (s) => s;
|
||||
eval(extractFunc(html, 'cardsPskMap'));
|
||||
eval(extractFunc(html, 'scriptsParseApdus'));
|
||||
eval(extractFunc(html, 'berLenStr'));
|
||||
eval(extractFunc(html, 'ramDeleteApdu'));
|
||||
eval(extractFunc(html, 'scp81DeleteApdus'));
|
||||
eval(extractFunc(html, 'scp81LogLine'));
|
||||
eval(extractFunc(html, 'scp81LogEntryHtml'));
|
||||
eval(extractFunc(html, 'scp81GroupResults'));
|
||||
eval(extractFunc(html, 'scp81ScriptStateText'));
|
||||
eval(extractFunc(html, 'scriptKindLabel'));
|
||||
global.esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
test('scriptKindLabel maps the stored kinds to the template labels', () => {
|
||||
assert.strictEqual(scriptKindLabel('explore'), 'Explore ISD');
|
||||
assert.strictEqual(scriptKindLabel('Explore'), 'Explore ISD');
|
||||
assert.strictEqual(scriptKindLabel('delete'), 'Delete AID');
|
||||
assert.strictEqual(scriptKindLabel('install'), 'Install from .cap');
|
||||
assert.strictEqual(scriptKindLabel('empty'), 'Empty');
|
||||
assert.strictEqual(scriptKindLabel(undefined), 'Empty');
|
||||
});
|
||||
|
||||
test('explore template is the reference administration sequence', () => { const m = /const SCP81_EXPLORE_APDUS = \[(.*?)\];/s.exec(html);
|
||||
assert.ok(m, 'SCP81_EXPLORE_APDUS not found');
|
||||
const apdus = [...m[1].matchAll(/'([0-9A-F]+)'/g)].map(x => x[1]);
|
||||
assert.deepStrictEqual(apdus, ['80CAFF2100', '80F28002024F0000', '80CA008500',
|
||||
'80F24002024F0000', '80F22002024F0000', '80F21002024F0000']);
|
||||
for (const a of apdus) {
|
||||
assert.ok(/^[0-9A-F]+$/.test(a) && a.length % 2 === 0, a);
|
||||
}
|
||||
});
|
||||
|
||||
test('cardsPskMap keeps only cards with both identity and key', () => {
|
||||
global.cards = [
|
||||
{name: 'A', pskIdentity: 'id-1', pskKey: '00112233445566778899aabbccddeeff'},
|
||||
{name: 'B', pskIdentity: 'id-2'}, // no key
|
||||
{name: 'C', pskKey: '00112233'}, // no identity
|
||||
{name: 'D', pskIdentity: 'id-4', pskKey: 'AA BB CC'}, // spaces stripped
|
||||
{name: 'E', pskIdentity: 'id-5', pskKey: 'not-hex'},
|
||||
];
|
||||
assert.deepStrictEqual(cardsPskMap(), [
|
||||
{identity: 'id-1', psk_hex: '00112233445566778899aabbccddeeff'},
|
||||
{identity: 'id-4', psk_hex: 'AABBCC'},
|
||||
]);
|
||||
global.cards = [];
|
||||
assert.deepStrictEqual(cardsPskMap(), []);
|
||||
});
|
||||
|
||||
test('scriptsParseApdus accepts comments and whitespace, rejects bad lines', () => {
|
||||
assert.deepStrictEqual(
|
||||
scriptsParseApdus('80CAFF2100\n\n80F2 4002 024F 0000 # listing\n; note\n80E60200AB00'),
|
||||
{apdus: ['80CAFF2100', '80F24002024F0000', '80E60200AB00']});
|
||||
assert.strictEqual(scriptsParseApdus('80CAFF2100').error, undefined);
|
||||
assert.deepStrictEqual(scriptsParseApdus('ZZ'), {error: 'ZZ'});
|
||||
assert.deepStrictEqual(scriptsParseApdus('80CAF'), {error: '80CAF'});
|
||||
assert.deepStrictEqual(scriptsParseApdus('80CAFF2'), {error: '80CAFF2'});
|
||||
});
|
||||
|
||||
test('scp81DeleteApdus builds GP DELETE APDUs per AID', () => {
|
||||
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000'], '00'),
|
||||
['80E400000A4F08A000000003000000']);
|
||||
assert.deepStrictEqual(scp81DeleteApdus(['A000000003000000', 'A000000100'], '80'),
|
||||
['80E400800A4F08A000000003000000', '80E40080074F05A000000100']);
|
||||
assert.deepStrictEqual(scp81DeleteApdus([], '00'), []);
|
||||
});
|
||||
|
||||
test('scp81LogEntryHtml marks matched and unknown PSK identities', () => {
|
||||
global.cards = [{name: 'Foobar SIM', pskIdentity: 'id-1'}];
|
||||
global.cardsPskName = (identity) =>
|
||||
(global.cards.find(c => c.pskIdentity === identity) || {}).name || '';
|
||||
const matched = scp81LogEntryHtml(
|
||||
{seq: 1, kind: 'tls-handshake', cipher: 'PSK-AES128-CBC-SHA256',
|
||||
identity: 'id-1', psk_match: true});
|
||||
assert.match(matched, /id=id-1/);
|
||||
assert.match(matched, /\[matched: Foobar SIM\]/);
|
||||
const unknown = scp81LogEntryHtml(
|
||||
{seq: 2, kind: 'tls-handshake', identity: 'who', psk_match: false});
|
||||
assert.match(unknown, /\[unknown identity\]/);
|
||||
const rejected = scp81LogEntryHtml({seq: 3, kind: 'tls-psk-unknown', identity: 'who'});
|
||||
assert.match(rejected, /\[unknown identity\]/);
|
||||
// non-handshake lines get no badge
|
||||
const plain = scp81LogEntryHtml({seq: 4, kind: 'script-send', index: 1, apdu: '80CAFF2100'});
|
||||
assert.doesNotMatch(plain, /\[\]/);
|
||||
});
|
||||
|
||||
test('scp81ScriptStateText separates script progress from listing pages', () => {
|
||||
// untouched / empty scripts show no state line
|
||||
assert.strictEqual(scp81ScriptStateText({kind: 'none', total: 0}), '');
|
||||
assert.strictEqual(scp81ScriptStateText({kind: 'Explore', total: 0}), '');
|
||||
// still executing the configured APDUs
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3], script: []}),
|
||||
'Explore: 4/6 executed');
|
||||
// a configured APDU is awaiting the card's report
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4],
|
||||
pending: {index: 9, pos: 5, page: false, apdu: '80F21002024F0000'}}),
|
||||
'Explore: 5/6 executed · waiting for card');
|
||||
// all script APDUs executed; only a listing page is in flight
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: {index: 17, pos: null, page: true, apdu: '80F21003024F0000'},
|
||||
pages: 11, complete: false}),
|
||||
'Explore: 6/6 executed · listing pages (11)…');
|
||||
// queued page, nothing sent yet
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: null, pages: 11, pages_queued: 1, complete: false}),
|
||||
'Explore: 6/6 executed · listing pages (11)…');
|
||||
// everything drained
|
||||
assert.strictEqual(
|
||||
scp81ScriptStateText({kind: 'Explore', total: 6, done: [0, 1, 2, 3, 4, 5],
|
||||
pending: null, pages: 11, pages_queued: 0, complete: true}),
|
||||
'Explore: 6/6 executed · completed');
|
||||
});
|
||||
|
||||
test('scp81GroupResults groups pages by originating command', () => {
|
||||
const groups = scp81GroupResults({results: [
|
||||
{index: 1, pos: 0, page: false, apdu: '80F24002024F0000', rapdu: 'E3', sw: 'CAFE'},
|
||||
{index: 2, pos: null, page: true, apdu: '80F24003024F0000', rapdu: 'E3', sw: '9000'},
|
||||
{index: 3, pos: 1, page: false, apdu: '80CAFF2100', rapdu: 'FF21', sw: '9000'},
|
||||
]});
|
||||
assert.strictEqual(groups.length, 2);
|
||||
assert.strictEqual(groups[0].key, '80F240');
|
||||
assert.strictEqual(groups[0].results.length, 2); // origin + continuation page
|
||||
assert.strictEqual(groups[1].key, '80CAFF');
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
// Extract chain builder functions and dependencies
|
||||
const FNS = ['buildApdu', 'buildSelect', 'escHtml', 'chainInit', 'chainSimBuildRowHex'];
|
||||
const FNS = ['buildApdu', 'buildSelect', 'escHtml', 'chainInit', 'chainKind', 'chainSimBuildRowHex'];
|
||||
let code = '';
|
||||
for (const f of FNS) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
@@ -226,3 +226,14 @@ test('SELECT by FID SIM (CLA A0): no Le anywhere', () => {
|
||||
const apdu = setup('sim', { cmd: 'select', selMethod: 'fid', fid: '6FC5' });
|
||||
assert.strictEqual(apdu, 'A0A40000026FC5');
|
||||
});
|
||||
|
||||
test('the proactive log renders a session boundary marker', () => {
|
||||
// v3.23.7: a TERMINAL PROFILE re-send keeps the log history and appends a
|
||||
// marker entry - the renderer draws it as a boundary line, not a command
|
||||
// row (and the marker branch must run before the normal row builder)
|
||||
const src = extractFunc(html, 'pysimProactiveLogRender');
|
||||
assert.ok(src.includes('if (e.marker)'), src.slice(0, 300));
|
||||
assert.ok(src.includes("t('TERMINAL PROFILE re-sent \\u2014 new CAT session')"), src);
|
||||
assert.ok(src.indexOf('if (e.marker)') < src.indexOf('CMD_NAMES[e.type_hex]'),
|
||||
'the marker branch must run before the normal row');
|
||||
});
|
||||
|
||||
+100
-11
@@ -27,10 +27,20 @@ function extractFunc(src, name) {
|
||||
}
|
||||
|
||||
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
|
||||
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
|
||||
'aesCmac', 'genSp'];
|
||||
'xorBytes', 'zeroPad', 'crc32Bytes', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
|
||||
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
|
||||
'spShowSizeInfo',
|
||||
// the counter of a built packet: typed override, else the preset's (v3.20.0)
|
||||
'spCntrEffective', 'spPresetCounterValue',
|
||||
// the key-version guard genSp runs before building (TS 102 225 A.2)
|
||||
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
|
||||
'spPresetIdx', 'spKeysetGuard'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) {
|
||||
code += html.match(new RegExp('const ' + c + ' = \\d+;'))[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
|
||||
// All test vectors are computed with the synthetic dummy key material below
|
||||
// (no live/sample card keys, no ICCIDs). They are cross-checked byte-for-byte
|
||||
@@ -90,16 +100,39 @@ test('ciphered + CC SPI 16/01 (counter_must_be_higher, plaintext PoR)', () => {
|
||||
'00201516011515B00000E42573469E68A8462A57A505B0E2B1C09C1928C7A182311F');
|
||||
});
|
||||
|
||||
test('unciphered + CC SPI 02/09', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' }),
|
||||
'1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
test('unciphered + CC SPI 02/09 carries the CPL', () => {
|
||||
const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
|
||||
assert.strictEqual(out,
|
||||
'001D1502091515B0000000000000010085A8CA1A9828B0BB00A40000023F00');
|
||||
assert.strictEqual(parseInt(out.slice(0, 4), 16), out.length / 2 - 2);
|
||||
});
|
||||
|
||||
test('unciphered packet starts at CHL, no CPL prefix (pySim parity)', () => {
|
||||
const out = makeRun({ 'sp-spi1': '02', 'sp-spi2-hex': '09' });
|
||||
assert.strictEqual(out.slice(0, 2), '15');
|
||||
assert.strictEqual(out.length, 58);
|
||||
test('unprotected packet (SPI 00) keeps the pySim CHL-first form', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09' }),
|
||||
'0D00091515B0000000000000010000A40000023F00');
|
||||
});
|
||||
|
||||
test('unprotected concatenated packet gains the CPL (TS 31.115 4.3)', () => {
|
||||
const out = makeRun({ 'sp-spi1': '00', 'sp-spi2-hex': '09', 'sp-apdu': 'A0'.repeat(200) });
|
||||
assert.strictEqual(out.length / 2, 216);
|
||||
assert.strictEqual(parseInt(out.slice(0, 4), 16), 214);
|
||||
assert.strictEqual(out.slice(4, 6), '0D');
|
||||
assert.strictEqual(scp80SegmentInfo(out).segments, 2);
|
||||
});
|
||||
|
||||
test('RC (SPI 01) computes CRC-32 over the CPL frame', () => {
|
||||
assert.strictEqual(
|
||||
makeRun({ 'sp-spi1': '01', 'sp-spi2-hex': '09' }),
|
||||
'00191101091515B0000000000000010050C942DC00A40000023F00');
|
||||
});
|
||||
|
||||
test('DS (SPI 03) is refused instead of building an unsigned packet', () => {
|
||||
assert.match(makeRun({ 'sp-spi1': '03' }), /Digital Signature/);
|
||||
});
|
||||
|
||||
test('crc32Bytes known answer (TS 102 225 Annex B)', () => {
|
||||
assert.strictEqual(bytesToHex(crc32Bytes(hexToBytes('0102030405'))), '470B99F4');
|
||||
});
|
||||
|
||||
test('sysmocom public reference vector (spi1 04 / spi2 19, cntr=0)', () => {
|
||||
@@ -169,7 +202,7 @@ test('AES unciphered + CC SPI 12/09 (counter higher)', () => {
|
||||
'sp-kic-key': KIC_AES,
|
||||
'sp-kid-key': KID_AES,
|
||||
}),
|
||||
'1512092222B0001100000000110029826122C7A0B79500A40004023F00');
|
||||
'001D1512092222B0001100000000110029826122C7A0B79500A40004023F00');
|
||||
});
|
||||
|
||||
test('AES ciphered + CC SPI 1E/19 (counter +1)', () => {
|
||||
@@ -217,3 +250,59 @@ test('AES rejects 8-byte key', () => {
|
||||
});
|
||||
assert.strictEqual(err, 'Error: AES KIc key must be 16, 24, or 32 bytes');
|
||||
});
|
||||
|
||||
test('spNextCntr increments with carry', () => {
|
||||
assert.strictEqual(spNextCntr('0000000001'), '0000000002');
|
||||
assert.strictEqual(spNextCntr('00000000FF'), '0000000100');
|
||||
assert.strictEqual(spNextCntr('000000FFFF'), '0000010000');
|
||||
assert.strictEqual(spNextCntr('0000ABCDEF'), '0000ABCDF0');
|
||||
});
|
||||
|
||||
test('spNextCntr keeps the full 5 bytes (no 32-bit truncation)', () => {
|
||||
assert.strictEqual(spNextCntr('10000AAAC8'), '10000AAAC9'); // the reported case
|
||||
assert.strictEqual(spNextCntr('1000 0AAAC8'), '10000AAAC9'); // separators stripped
|
||||
assert.strictEqual(spNextCntr('FFFFFFFFF7'), 'FFFFFFFFF8');
|
||||
assert.strictEqual(spNextCntr('FFFFFFFFFF'), '0000000000'); // 40-bit wrap
|
||||
assert.strictEqual(spNextCntr('10000000000'), '0000000001'); // out of range clamps
|
||||
});
|
||||
|
||||
test('spNextCntr tolerates lower case and separators', () => {
|
||||
assert.strictEqual(spNextCntr('00000000 0a'), '000000000B');
|
||||
assert.strictEqual(spNextCntr(''), '0000000001');
|
||||
});
|
||||
|
||||
test('scp80SegmentInfo mirrors the server segmentation rules', () => {
|
||||
assert.deepStrictEqual(scp80SegmentInfo(''), { bytes: 0, segments: 0 });
|
||||
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(137)), { bytes: 137, segments: 1 });
|
||||
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(138)), { bytes: 138, segments: 2 });
|
||||
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134)), { bytes: 266, segments: 2 });
|
||||
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134 * 2)), { bytes: 400, segments: 3 });
|
||||
});
|
||||
|
||||
test('spSizeInfoText reports size, SMS count and the card buffer limit', () => {
|
||||
assert.strictEqual(spSizeInfoText('AA'.repeat(18)), '18 bytes · 1 SMS');
|
||||
assert.strictEqual(spSizeInfoText('AA'.repeat(266)), '266 bytes · 2 SMS (concatenated)');
|
||||
assert.match(spSizeInfoText('AA'.repeat(132 + 134 * 5)),
|
||||
/too large for the card concatenation buffer \(5 SMS\)/);
|
||||
assert.strictEqual(spSizeInfoText(''), '');
|
||||
});
|
||||
|
||||
test('the SCP80 UI constants match the server segmentation constants', () => {
|
||||
const py = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'pysim_simple_server', 'server.py'), 'utf8');
|
||||
const read = (name) => {
|
||||
const m = new RegExp('^' + name + '\\s*=\\s*(\\d+)', 'm').exec(py);
|
||||
assert.ok(m, name + ' not found in server.py');
|
||||
return parseInt(m[1], 10);
|
||||
};
|
||||
assert.strictEqual(SCP80_SINGLE_BYTES, read('SCP80_SINGLE_BYTES'));
|
||||
assert.strictEqual(SCP80_FIRST_BYTES, read('SCP80_FIRST_BYTES'));
|
||||
assert.strictEqual(SCP80_NEXT_BYTES, read('SCP80_NEXT_BYTES'));
|
||||
assert.strictEqual(SCP80_MAX_SMS, read('MAX_ENVELOPE_SEGMENTS'));
|
||||
});
|
||||
|
||||
test('a generated single-SMS packet shows its size and SMS count', () => {
|
||||
const pkt = makeRun({});
|
||||
const info = elements['sp-size-info'] || {};
|
||||
assert.strictEqual(info.textContent, (pkt.length / 2) + ' bytes · 1 SMS');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
|
||||
code += extractFunc(html, 'stkNoPendingError') + '\n';
|
||||
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
|
||||
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
|
||||
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
|
||||
code += extractFunc(html, 'stkMenuLabel') + '\n';
|
||||
code += extractFunc(html, 'stkInputHtml') + '\n';
|
||||
code += extractFunc(html, 'stkInputHint') + '\n';
|
||||
code += extractFunc(html, 'stkInputValidate') + '\n';
|
||||
code += 'globalThis.esc = s => s;\n';
|
||||
eval(code);
|
||||
|
||||
function setup(response) {
|
||||
const calls = { handled: null, rendered: 0 };
|
||||
globalThis.pysimFetch = async () => response;
|
||||
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
|
||||
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
|
||||
globalThis.stkCheckMenu = () => {};
|
||||
const btns = { classList: { add: () => {} } };
|
||||
const back = { style: {} };
|
||||
globalThis.document = {
|
||||
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back
|
||||
: { innerHTML: '', classList: { add: () => {} } }),
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('the STK menu item suffix shows the item next action (8.24)', () => {
|
||||
assert.strictEqual(stkMenuNaiSuffix({ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' }),
|
||||
'\u25b8 SET UP MENU');
|
||||
// no NAI (or a reserved one, which the server drops) -> no suffix
|
||||
assert.strictEqual(stkMenuNaiSuffix({ id: 2, text: 'Info' }), '');
|
||||
assert.strictEqual(stkMenuNaiSuffix(null), '');
|
||||
});
|
||||
|
||||
test('the overlay item list shows the next action and the row handler', () => {
|
||||
const list = stkMenuItemsHtml([
|
||||
{ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' },
|
||||
{ id: 2, text: 'Info' },
|
||||
], 'stkSubItemClick');
|
||||
assert.match(list, /onclick="stkSubItemClick\(1\)"/);
|
||||
assert.match(list, /onclick="stkSubItemClick\(2\)"/);
|
||||
assert.ok(list.includes('Menu'));
|
||||
assert.ok(list.includes('\u25b8 SET UP MENU'));
|
||||
// the second item has no indicator -> exactly one suffix in the list
|
||||
assert.strictEqual((list.match(/\u25b8/g) || []).length, 1);
|
||||
assert.strictEqual(stkMenuItemsHtml(null, 'stkMenuItemClick'), '<div class="space-y-1"></div>');
|
||||
});
|
||||
|
||||
test('both overlay lists render through the shared row helper', () => {
|
||||
// the cached top menu and the pending SELECT ITEM items must use the same
|
||||
// renderer, otherwise the latter silently loses the NAI suffix
|
||||
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkMenuItemClick'\)/);
|
||||
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkSubItemClick'\)/);
|
||||
});
|
||||
|
||||
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back with a fetched DISPLAY TEXT shows it', async () => {
|
||||
const data = { type: 'display_text', text: 'hello' };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
|
||||
const data = { type: 'select_item', items: [] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('timeout');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('back answered with SW 9000 falls back to the cached top menu', async () => {
|
||||
const calls = setup({ type: 'done', sw: '9000' });
|
||||
await stkMenuRespond('back');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('cancel falls back to the cached top menu', async () => {
|
||||
const calls = setup({ sw: '9000' });
|
||||
await stkMenuRespond('cancel');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 1);
|
||||
});
|
||||
|
||||
test('ok navigates with the server response', async () => {
|
||||
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
|
||||
const calls = setup(data);
|
||||
await stkMenuRespond('ok');
|
||||
assert.strictEqual(calls.handled, data);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('stkNoPendingError detects the already-answered reply', () => {
|
||||
assert.strictEqual(stkNoPendingError({ error: 'no pending command' }), true);
|
||||
assert.strictEqual(stkNoPendingError({ sw: '9000', type: 'done' }), false);
|
||||
assert.strictEqual(stkNoPendingError({ error: 'other' }), false);
|
||||
assert.strictEqual(stkNoPendingError(null), false);
|
||||
});
|
||||
|
||||
test('an already-answered respond does not reach the dialog handler', async () => {
|
||||
const calls = setup({ error: 'no pending command' });
|
||||
await stkMenuRespond('ok');
|
||||
assert.strictEqual(calls.handled, null);
|
||||
assert.strictEqual(calls.rendered, 0);
|
||||
});
|
||||
|
||||
test('stkInputHtml builds the card-driven input field', () => {
|
||||
globalThis.t = s => s;
|
||||
const out = stkInputHtml({ type: 'get_input', text: 'PIN', min: 4, max: 16,
|
||||
digits_only: true, hidden: true });
|
||||
assert.ok(out.includes('PIN'), out);
|
||||
assert.ok(out.includes('type="password"'), out); // hidden entry
|
||||
assert.ok(out.includes('inputmode="numeric"'), out); // digits only
|
||||
assert.ok(out.includes('maxlength="16"'), out);
|
||||
assert.ok(out.includes('4\u201316 characters'), out);
|
||||
assert.ok(out.includes('hidden entry'), out);
|
||||
// UCS2 alphabet entry: a plain text field without the numeric keyboard
|
||||
const ucs2 = stkInputHtml({ type: 'get_input', text: 'Name', min: 0, max: 10, ucs2: true });
|
||||
assert.ok(ucs2.includes('type="text"'), ucs2);
|
||||
assert.ok(!ucs2.includes('inputmode'), ucs2);
|
||||
assert.ok(ucs2.includes('up to 10 characters'), ucs2);
|
||||
// 0xFF = no maximum (TS 102 223 8.11): no maxlength and no range hint
|
||||
const noMax = stkInputHtml({ type: 'get_input', text: 'x', min: 0, max: 255 });
|
||||
assert.ok(!noMax.includes('maxlength'), noMax);
|
||||
assert.ok(!noMax.includes('characters'), noMax);
|
||||
// Yes/No: buttons instead of a field
|
||||
const yesNo = stkInputHtml({ type: 'get_inkey', text: 'Continue?', yes_no: true });
|
||||
assert.ok(yesNo.includes("stkInputSubmit('01')"), yesNo);
|
||||
assert.ok(yesNo.includes("stkInputSubmit('00')"), yesNo);
|
||||
assert.ok(!yesNo.includes('stk-input-field'), yesNo);
|
||||
// the card's default text is prefilled; help adds the help action
|
||||
const def = stkInputHtml({ type: 'get_input', text: 'x', default: '1234', help: true });
|
||||
assert.ok(def.includes('value="1234"'), def);
|
||||
assert.ok(def.includes("stkMenuRespond('help')"), def);
|
||||
});
|
||||
|
||||
test('stkInputValidate mirrors the server input rules', () => {
|
||||
globalThis.t = s => s;
|
||||
const pd = { type: 'get_input', min: 4, max: 16, digits_only: true, hidden: true };
|
||||
assert.strictEqual(stkInputValidate(pd, '1234').ok, true);
|
||||
assert.strictEqual(stkInputValidate(pd, '12').ok, false);
|
||||
assert.strictEqual(stkInputValidate(pd, '12345678901234567').ok, false);
|
||||
assert.strictEqual(stkInputValidate(pd, 'abcd').ok, false);
|
||||
// hidden entry allows only the digits set even without digits_only
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_input', hidden: true }, 'ab').ok, false);
|
||||
// GET INKEY: exactly one character, digits-only honours * # +
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'a').ok, true);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'ab').ok, false);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
|
||||
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
|
||||
});
|
||||
|
||||
test('stkMenuLabel shows the card title or a generic label', () => {
|
||||
globalThis.t = s => s;
|
||||
// the card's title when it sent one
|
||||
assert.strictEqual(stkMenuLabel({ title: 'Alfa Mobile', items: [{ id: 1 }] }), 'Alfa Mobile');
|
||||
// a title-less SET UP MENU still shows the menu (TS 102 223 6.6.7)
|
||||
assert.strictEqual(stkMenuLabel({ items: [{ id: 1 }] }), 'Menu');
|
||||
// no items = no menu, whatever the title
|
||||
assert.strictEqual(stkMenuLabel({ title: 'x', items: [] }), '');
|
||||
assert.strictEqual(stkMenuLabel({ items: [] }), '');
|
||||
assert.strictEqual(stkMenuLabel(null), '');
|
||||
});
|
||||
|
||||
test('the STK menu button shows the title without an STK: prefix', () => {
|
||||
// the block's legend already says "STK menu" - the prefix was redundant
|
||||
assert.match(html, /id="stk-menu-btn"[^>]*><span id="stk-menu-title"><\/span><\/button>/);
|
||||
assert.ok(!/>STK: </.test(html), 'the redundant "STK: " prefix must be gone');
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['berLenStr', 'stkParamsBuild', 'buildRcToolkitParams',
|
||||
'updateStkParamsHex', 'updateInstallParamsHex', 'adfAidFromFci', 'ramQuotaValue', 'ramQuotaTlvs',
|
||||
'composeRamInstallParams', 'ramInstallParamsCompose',
|
||||
'parseTlvList', 'parseBerLen']) code += extractFunc(html, fn) + '\n';
|
||||
eval(code);
|
||||
|
||||
const base = { mode: 'ea', priority: '0', timers: '0', textLen: '0', menus: '0',
|
||||
firstPos: '0', firstId: '00', lastPos: '0', lastId: '00',
|
||||
channels: '0', msl: '00', tar: '', ad: '', services: '0' };
|
||||
function vals(over) { return Object.assign({}, base, over); }
|
||||
|
||||
// ===== install-parameter coding (TS 102 226 8.2.1.3.2.2.1) =====
|
||||
|
||||
test('UICC (EA) parameters match the decrypted live packet', () => {
|
||||
// The install parameters of the live CAP install (decrypted):
|
||||
// EA 0F | 80 0D <prio 0 timers 0 textLen 0 menus 0> <channels 1>
|
||||
// <MSL len 2 / 01 12> <TAR len 3 / AF 4D 01> <services 0>
|
||||
assert.strictEqual(
|
||||
stkParamsBuild(vals({ channels: '1', msl: '12', tar: 'AF4D01' })),
|
||||
'EA0F800D000000000102011203AF4D0100');
|
||||
});
|
||||
|
||||
test('empty TAR emits a zero-length TAR field, never a B00001 default', () => {
|
||||
// B0 00 01 is the allocated ADF RFM TAR (TS 101 220 Annex D), not an
|
||||
// applet TAR: the field stays empty and the TAR Value(s) length is 00
|
||||
// (TS 102 226 8.2.1.3.2.7 - the card may then use a TAR from the AID,
|
||||
// but only when the AID carries one).
|
||||
const out = stkParamsBuild(vals({}));
|
||||
assert.strictEqual(out, 'EA0A80080000000000000000');
|
||||
assert.ok(!out.includes('B00001'), out);
|
||||
});
|
||||
|
||||
test('SIM (CA) parameters carry the access domain first and no services byte', () => {
|
||||
assert.strictEqual(stkParamsBuild(vals({ mode: 'ca' })),
|
||||
'EF0ACA080000000000000000');
|
||||
assert.strictEqual(stkParamsBuild(vals({ mode: 'ca', ad: '00' })),
|
||||
'EF0BCA09010000000000000000');
|
||||
});
|
||||
|
||||
test('menu item pairs run first .. last with 0000 fillers between', () => {
|
||||
assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '7F' })),
|
||||
'EA0C800A00000001007F00000000');
|
||||
const out = stkParamsBuild(vals({ menus: '3', firstPos: '1', firstId: '01',
|
||||
lastPos: '3', lastId: '03' }));
|
||||
assert.ok(out.includes('010100000303'), out);
|
||||
});
|
||||
|
||||
test('long menu lists use BER long-form lengths', () => {
|
||||
const out = stkParamsBuild(vals({ menus: '60', firstPos: '1', firstId: '01',
|
||||
lastPos: '60', lastId: '3C', msl: '16', tar: 'AF4D01' }));
|
||||
assert.ok(out.startsWith('EA8188808185'), out);
|
||||
});
|
||||
|
||||
test('rejects a TAR that is not a whole number of 3-byte values', () => {
|
||||
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D' })), null);
|
||||
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0' })), null);
|
||||
assert.strictEqual(stkParamsBuild(vals({ tar: 'AF4D0102' })), null);
|
||||
// several TAR values are allowed (3 bytes each)
|
||||
assert.ok(stkParamsBuild(vals({ tar: 'AF4D01AFFA01' })).includes('06AF4D01AFFA01'));
|
||||
});
|
||||
|
||||
test('rejects menu item identifiers above 7F (reserved for the toolkit framework)', () => {
|
||||
assert.strictEqual(stkParamsBuild(vals({ menus: '1', firstId: '80' })), null);
|
||||
assert.strictEqual(stkParamsBuild(vals({ menus: '1', lastId: 'FF' })), null);
|
||||
});
|
||||
|
||||
// ===== form wiring: the STK settings must reach the sent hex =====
|
||||
|
||||
test('every toolkit field regenerates the STK parameters hex on edit', () => {
|
||||
// v3.6.9: the install used to send the hex computed once when the
|
||||
// checkbox was ticked - edits to the fields afterwards were dropped
|
||||
// (the live install carried the defaults, not the entered TAR).
|
||||
const block = html.slice(html.indexOf('id="rc-toolkit-body"'),
|
||||
html.indexOf('id="ram-install-params-hex"'));
|
||||
const re = /<(?:input|select)\b[^>]*id="(rc-tk-[^"]+)"[^>]*>/g;
|
||||
const seen = [];
|
||||
let m;
|
||||
while ((m = re.exec(block))) {
|
||||
seen.push(m[1]);
|
||||
assert.ok(/on(?:input|change)="[^"]*updateStkParamsHex/.test(m[0]),
|
||||
m[1] + ' does not refresh the hex: ' + m[0]);
|
||||
}
|
||||
assert.strictEqual(seen.length, 17, 'expected 17 toolkit fields, got ' + seen.join(', '));
|
||||
});
|
||||
|
||||
test('the applet TAR field has no B00001 default or placeholder', () => {
|
||||
const m = /<input id="rc-tk-tar"[^>]*>/.exec(html);
|
||||
assert.ok(m, 'rc-tk-tar not found');
|
||||
assert.ok(!/value="B00001"/.test(m[0]), m[0]);
|
||||
assert.ok(!/placeholder="B00001"/.test(m[0]), m[0]);
|
||||
assert.ok(/oninput="updateStkParamsHex\(\)"/.test(m[0]), m[0]);
|
||||
assert.ok(!html.includes("f.tkTar || 'B00001'"), 'the chain toolkit default must stay empty');
|
||||
});
|
||||
|
||||
test('adfAidFromFci extracts the DF name (tag 84) from an FCI', () => {
|
||||
// the live ADF.USIM FCI (read from the card, 2026-09-28): the DF name is
|
||||
// 12 bytes - the base A0000000871002 is only a prefix, and a shorter AID
|
||||
// makes the card reject the access entry with 6A80.
|
||||
assert.strictEqual(
|
||||
adfAidFromFci('622882027821840CA0000000871002FF49FF05898A01058B032F0617C60C90016083010183010A830181'),
|
||||
'A0000000871002FF49FF0589');
|
||||
// an FCI without a DF name, and garbage
|
||||
assert.strictEqual(adfAidFromFci('6207820278218A0105'), '');
|
||||
assert.strictEqual(adfAidFromFci(''), '');
|
||||
assert.strictEqual(adfAidFromFci('ZZZZ'), '');
|
||||
});
|
||||
|
||||
test('composeRamInstallParams composes C9 + EF (C7/C8) like the vendor scripts', () => {
|
||||
// The vendor's working install (samples/uicc/applets/STK3):
|
||||
// C9 22 <config> EF 08 C7 02 0000 C8 02 0000 EA ...
|
||||
// C9 is mandatory (GP Table 11-49): an empty field is sent as C9 00 -
|
||||
// without it the card rejects the data field with 6A80 (live 2026-09-28).
|
||||
assert.strictEqual(composeRamInstallParams('', '', '', '', '').install_params, 'C900');
|
||||
assert.strictEqual(
|
||||
composeRamInstallParams('082905112000012066', '', '', '', '').install_params,
|
||||
'C909082905112000012066');
|
||||
assert.strictEqual(composeRamInstallParams('', '0', '0', '', '').install_params,
|
||||
'C900EF08C7020000C8020000');
|
||||
assert.strictEqual(composeRamInstallParams('', '32768', '', '', '').install_params,
|
||||
'C900EF06C70400008000');
|
||||
assert.strictEqual(composeRamInstallParams('AA', '', '1234', '', '').install_params,
|
||||
'C901AAEF04C80204D2');
|
||||
// extra raw TLVs follow the generated prefix
|
||||
assert.strictEqual(composeRamInstallParams('', '', '', 'CB02AABB', '').install_params,
|
||||
'C900CB02AABB');
|
||||
// a C9 above 255 bytes uses the 2-byte BER length (0x12C = 300)
|
||||
const longC9 = 'AA'.repeat(300);
|
||||
assert.strictEqual(composeRamInstallParams(longC9, '', '', '', '').install_params,
|
||||
'C982012C' + longC9);
|
||||
});
|
||||
|
||||
test('an EF-form STK part takes the quotas into its own EF (one EF, TS 102 226 8.2.1.3.2.1)', () => {
|
||||
// Two EFs made the card read the quotas and ignore the CA (live
|
||||
// 2026-10-05: por_ok but no STK menu): the CA and the quotas share one
|
||||
// System Specific Parameters EF (GPD_SPE_013 Table 6-5).
|
||||
const ca = stkParamsBuild(vals({ mode: 'ca' })); // EF0ACA08...
|
||||
assert.strictEqual(ca, 'EF0ACA080000000000000000');
|
||||
const c = composeRamInstallParams('', '100', '', '', ca);
|
||||
assert.strictEqual(c.install_params, 'C900');
|
||||
assert.strictEqual(c.stk_params, 'EF0EC7020064CA080000000000000000');
|
||||
assert.strictEqual(c.quota_in_stk, true);
|
||||
// without quotas the EF passes through unchanged
|
||||
const c2 = composeRamInstallParams('', '', '', '', ca);
|
||||
assert.strictEqual(c2.install_params, 'C900');
|
||||
assert.strictEqual(c2.stk_params, ca);
|
||||
assert.strictEqual(c2.quota_in_stk, false);
|
||||
// an EA-form STK part is a sibling of EF: the quotas keep their own EF
|
||||
const ea = stkParamsBuild(vals({ mode: 'ea' }));
|
||||
const c3 = composeRamInstallParams('', '100', '', '', ea);
|
||||
assert.strictEqual(c3.install_params, 'C900EF04C7020064');
|
||||
assert.strictEqual(c3.stk_params, ea);
|
||||
assert.strictEqual(c3.quota_in_stk, false);
|
||||
});
|
||||
|
||||
test('the RAM form composes the install parameters from its fields', () => {
|
||||
const els = fakeForm({ 'rc-c9': 'AA', 'rc-quota-c7': '0' });
|
||||
assert.deepStrictEqual(ramInstallParamsCompose(),
|
||||
{ install_params: 'C901AAEF04C7020000', stk_params: '', quota_in_stk: false });
|
||||
els['rc-quota-c8'].value = '5';
|
||||
assert.strictEqual(ramInstallParamsCompose().install_params, 'C901AAEF08C7020000C8020005');
|
||||
// a CA-form STK field takes the quotas into its EF
|
||||
els['ram-stk-params'].value = 'EF0ACA080000000000000000';
|
||||
const c = ramInstallParamsCompose();
|
||||
assert.strictEqual(c.install_params, 'C901AA');
|
||||
assert.strictEqual(c.stk_params, 'EF12C7020000C8020005CA080000000000000000');
|
||||
assert.strictEqual(c.quota_in_stk, true);
|
||||
});
|
||||
|
||||
test('updateStkParamsHex names the rejection reason', () => {
|
||||
globalThis.t = s => s;
|
||||
const els = fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea',
|
||||
'rc-tk-tar': 'AF4D' }); // not a multiple of 3 bytes
|
||||
updateStkParamsHex();
|
||||
assert.strictEqual(els['ram-stk-params'].value, '');
|
||||
assert.ok(els['ram-stk-hint'].textContent.includes('STK parameters not generated'),
|
||||
els['ram-stk-hint'].textContent);
|
||||
// a valid form clears the hint
|
||||
els['rc-tk-tar'].value = 'AF4D01';
|
||||
updateStkParamsHex();
|
||||
assert.ok(els['ram-stk-params'].value.startsWith('EA'), els['ram-stk-params'].value);
|
||||
assert.strictEqual(els['ram-stk-hint'].textContent, '');
|
||||
delete globalThis.t;
|
||||
});
|
||||
|
||||
test('the install body composes the C9/quota prefix', () => {
|
||||
// v3.28.0: the shared field builder (the single install and the batch)
|
||||
const fn = extractFunc(html, 'ramInstallCapFields');
|
||||
assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(fn), fn);
|
||||
assert.ok(/install_params: composed\.install_params/.test(fn), fn);
|
||||
assert.ok(/stk_params: composed\.stk_params/.test(fn), fn);
|
||||
const app = extractFunc(html, 'ramInstallApp');
|
||||
assert.ok(/const composed = ramInstallParamsCompose\(\)/.test(app), app);
|
||||
assert.ok(/install_params: composed\.install_params/.test(app), app);
|
||||
});
|
||||
|
||||
test('the ADF AID From-card button is wired', () => {
|
||||
assert.ok(/id="rc-tk-adfaid-from-card"[^>]*onclick="ramAdfAidFromCard\(\)"/.test(html),
|
||||
'the ADF AID From-card button is missing');
|
||||
assert.ok(/async function ramAdfAidFromCard\(\)/.test(html), 'ramAdfAidFromCard is missing');
|
||||
assert.ok(/pysimFetch\('\/api\/select', \{ name: 'ADF.USIM' \}\)/.test(html),
|
||||
'the button must read the ADF.USIM FCI');
|
||||
});
|
||||
|
||||
test('the RAM install recomputes the STK hex at send time unless hand-edited', () => {
|
||||
assert.ok(/oninput="this\.dataset\.manual='1'"/.test(html),
|
||||
'the hex field must flag manual edits');
|
||||
assert.ok(/rc-toolkit-enable'\)\.checked && !\(stkEl\.dataset && stkEl\.dataset\.manual\)/.test(html),
|
||||
'the send-time recompute guard is missing');
|
||||
});
|
||||
|
||||
// ===== form -> hex (the live regression) =====
|
||||
|
||||
function fakeForm(values) {
|
||||
const ids = ['rc-toolkit-enable', 'rc-tk-mode', 'rc-tk-priority', 'rc-tk-timers',
|
||||
'rc-tk-textlen', 'rc-tk-menus', 'rc-tk-firstpos', 'rc-tk-firstid',
|
||||
'rc-tk-lastpos', 'rc-tk-lastid', 'rc-tk-channels', 'rc-tk-msl',
|
||||
'rc-tk-tar', 'rc-tk-ad', 'rc-tk-services', 'rc-tk-fsaccess',
|
||||
'rc-tk-adfaccess', 'rc-tk-adfaid', 'rc-c9', 'rc-quota-c7',
|
||||
'rc-quota-c8', 'ram-stk-params', 'ram-stk-hint'];
|
||||
const checks = ['rc-toolkit-enable', 'rc-tk-fsaccess', 'rc-tk-adfaccess'];
|
||||
const els = {};
|
||||
for (const id of ids) els[id] = { value: '', checked: false, dataset: {},
|
||||
classList: { add() {}, remove() {} } };
|
||||
for (const [id, v] of Object.entries(values || {})) {
|
||||
if (checks.indexOf(id) >= 0) els[id].checked = !!v;
|
||||
else els[id].value = v;
|
||||
}
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
test('the RAM form fields build the live install parameters end to end', () => {
|
||||
// The reported regression: the values were entered after the toolkit
|
||||
// checkbox was ticked and never reached the sent hex - the install
|
||||
// carried TAR B00001 / MSL 16 / channels 0 and the card answered 6A80.
|
||||
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
|
||||
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1' });
|
||||
assert.strictEqual(buildRcToolkitParams(), 'EA0F800DFF0000000102011203AF4D0100');
|
||||
});
|
||||
|
||||
test('UICC file-access parameters (82) are appended in EA mode', () => {
|
||||
// TS 102 226 8.2.1.3.2.2/8.2.1.3.2.2.2: the file-access list is the
|
||||
// tag '81' field ('82' is the *Administrative* Access field); every entry
|
||||
// ends with the "Length of Access Domain DAP" byte (00 = no DAP):
|
||||
// [FS AID len 00 = shared FS][AD len 01][ADP 00 = full][DAP len 00]
|
||||
// [ADF AID len][ADF AID][AD len 01][ADP 00][DAP len 00]
|
||||
// The SIM path grants the same rights via the CA Access Domain field; the
|
||||
// ADF entry is an extension of the file-system entry. Under '82' the card
|
||||
// rejected the ADF entry with 6A80 (live 2026-09-28).
|
||||
const base = vals({ channels: '1', msl: '12', tar: 'AF4D01' });
|
||||
assert.strictEqual(stkParamsBuild(Object.assign({}, base, { fsAccess: true })),
|
||||
'EA15800D000000000102011203AF4D0100810400010000');
|
||||
assert.strictEqual(
|
||||
stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true })),
|
||||
'EA20800D000000000102011203AF4D0100810F0001000007A0000000871002010000');
|
||||
assert.strictEqual(
|
||||
stkParamsBuild(Object.assign({}, base, { fsAccess: true, adfAccess: true,
|
||||
adfAid: 'A0000000871002FF33FFFF89010101' })),
|
||||
'EA28800D000000000102011203AF4D01008117000100000FA0000000871002FF33FFFF89010101010000');
|
||||
assert.strictEqual(stkParamsBuild(base), 'EA0F800D000000000102011203AF4D0100');
|
||||
assert.strictEqual(stkParamsBuild(Object.assign({}, base, { adfAccess: true })),
|
||||
'EA0F800D000000000102011203AF4D0100');
|
||||
// the ADF AID must be 5..16 bytes
|
||||
assert.strictEqual(stkParamsBuild(Object.assign({}, base,
|
||||
{ fsAccess: true, adfAccess: true, adfAid: 'A00000' })), null);
|
||||
assert.strictEqual(stkParamsBuild(Object.assign({}, base,
|
||||
{ fsAccess: true, adfAccess: true, adfAid: 'A0'.repeat(17) })), null);
|
||||
});
|
||||
|
||||
test('the RAM form emits full file access when the checkbox is ticked', () => {
|
||||
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
|
||||
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true });
|
||||
assert.strictEqual(buildRcToolkitParams(),
|
||||
'EA15800DFF0000000102011203AF4D0100810400010000');
|
||||
fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea', 'rc-tk-msl': '12',
|
||||
'rc-tk-tar': 'AF4D01', 'rc-tk-channels': '1', 'rc-tk-fsaccess': true,
|
||||
'rc-tk-adfaccess': true });
|
||||
assert.strictEqual(buildRcToolkitParams(),
|
||||
'EA20800DFF0000000102011203AF4D0100810F0001000007A0000000871002010000');
|
||||
});
|
||||
|
||||
test('updateStkParamsHex refreshes the field and clears the manual flag', () => {
|
||||
const els = fakeForm({ 'rc-toolkit-enable': true, 'rc-tk-mode': 'ea',
|
||||
'rc-tk-tar': 'AF4D01' });
|
||||
els['ram-stk-params'].dataset.manual = '1';
|
||||
updateStkParamsHex();
|
||||
assert.ok(els['ram-stk-params'].value.startsWith('EA'), els['ram-stk-params'].value);
|
||||
assert.ok(!els['ram-stk-params'].dataset.manual,
|
||||
'a regeneration supersedes a manual edit');
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const swSource = fs.readFileSync(path.join(__dirname, '..', 'sw.js'), 'utf8');
|
||||
|
||||
class FakeResponse {
|
||||
constructor(body, init) {
|
||||
this.body = body;
|
||||
this.status = init && init.status;
|
||||
this.statusText = init && init.statusText;
|
||||
}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, { status: this.status, statusText: this.statusText });
|
||||
}
|
||||
}
|
||||
|
||||
function loadSW({ fetchImpl, cacheMatch }) {
|
||||
const listeners = {};
|
||||
const puts = [];
|
||||
const sandbox = {
|
||||
self: {
|
||||
addEventListener: (type, fn) => { listeners[type] = fn; },
|
||||
skipWaiting: () => {},
|
||||
},
|
||||
caches: {
|
||||
open: async () => ({
|
||||
addAll: async () => {},
|
||||
put: async (req, res) => { puts.push([String(req && req.url || req), res]); },
|
||||
}),
|
||||
keys: async () => [],
|
||||
delete: async () => true,
|
||||
match: cacheMatch,
|
||||
},
|
||||
clients: { claim: () => {} },
|
||||
fetch: fetchImpl,
|
||||
Response: FakeResponse,
|
||||
URL,
|
||||
console,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(swSource, sandbox);
|
||||
return { listeners, puts };
|
||||
}
|
||||
|
||||
function navigateEvent(url) {
|
||||
const event = {
|
||||
request: { url, method: 'GET', mode: 'navigate' },
|
||||
responded: null,
|
||||
};
|
||||
event.respondWith = p => { event.responded = p; };
|
||||
event.passThrough = () => { event.responded = null; };
|
||||
return event;
|
||||
}
|
||||
|
||||
test('install precaches every URL with cache: reload (no stale HTTP copies)', async () => {
|
||||
const calls = [];
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push([String(url), init && init.cache]);
|
||||
return { ok: true, status: 200 };
|
||||
},
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = { waitUntil: p => { event.promise = p; } };
|
||||
listeners.install(event);
|
||||
await event.promise;
|
||||
assert.ok(calls.length >= 10, String(calls.length));
|
||||
assert.ok(calls.every(c => c[1] === 'reload'), JSON.stringify(calls[0]));
|
||||
assert.strictEqual(puts.length, calls.length);
|
||||
});
|
||||
|
||||
test('offline navigation falls back to the cached index.html', async () => {
|
||||
const index = new FakeResponse('html');
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async req => (String(req && req.url || req) === 'index.html' ? index : undefined),
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, index);
|
||||
});
|
||||
|
||||
test('offline navigation with empty cache resolves to an offline Response', async () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('offline'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.ok(res instanceof FakeResponse);
|
||||
assert.strictEqual(res.status, 503);
|
||||
});
|
||||
|
||||
test('a successful navigation is cached and returned', async () => {
|
||||
const page = new FakeResponse('html');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => page,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/help.html');
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, page);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
assert.strictEqual(puts[0][0], 'http://127.0.0.1:8080/help.html');
|
||||
});
|
||||
|
||||
test('api requests bypass the service worker', () => {
|
||||
const { listeners } = loadSW({
|
||||
fetchImpl: async () => { throw new Error('unexpected'); },
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/api/status');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
assert.strictEqual(event.responded, null);
|
||||
});
|
||||
|
||||
test('uncached asset hits the network and gets cached', async () => {
|
||||
const asset = new FakeResponse('js');
|
||||
const { listeners, puts } = loadSW({
|
||||
fetchImpl: async () => asset,
|
||||
cacheMatch: async () => undefined,
|
||||
});
|
||||
const event = navigateEvent('http://127.0.0.1:8080/des-bundle.js');
|
||||
event.request.mode = 'cors';
|
||||
listeners.fetch(event);
|
||||
const res = await event.responded;
|
||||
assert.strictEqual(res, asset);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.strictEqual(puts.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['esc', 'escHtml', 'tarProbeVerdictLabel', 'tarProbeHtml',
|
||||
'tarProbeRowsHtml', 'tarProbeAllRows', 'tarProbeChecked', 'cardsTarList']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'function t(s){return s;}\n';
|
||||
eval(code);
|
||||
|
||||
test('tarProbeRowsHtml renders the preset rows checked', () => {
|
||||
// v3.26.0: the rows come from the preset - no custom rows, no remove
|
||||
// buttons
|
||||
const out = tarProbeRowsHtml([
|
||||
{ tar: '000000', label: 'ISD', doc: 'MSL 16 \u00b7 Keyset 1' },
|
||||
{ tar: 'AF4D02', label: 'My applet' },
|
||||
]);
|
||||
assert.match(out, /value="000000" checked/);
|
||||
assert.match(out, /title="MSL 16/);
|
||||
assert.match(out, /ISD/);
|
||||
assert.match(out, /value="AF4D02" checked/);
|
||||
assert.ok(!out.includes('tarProbeRemoveCustom'), 'no custom rows anymore');
|
||||
// escaping
|
||||
const evil = tarProbeRowsHtml([{ tar: 'AA<BB', label: '<i>x' }]);
|
||||
assert.ok(!evil.includes('AA<BB'));
|
||||
assert.ok(!evil.includes('<i>x'));
|
||||
});
|
||||
|
||||
test('tarProbeAllRows lists the selected preset TARs (v3.26.0)', () => {
|
||||
globalThis.cards = [{ name: 'C', tars: [
|
||||
{ role: 'isd', tar: '000000', msl: '16', keyset: 1 },
|
||||
{ tar: 'AF4D02', msl: '0A', desc: 'My applet' }] }];
|
||||
globalThis.tarPresetIdx = () => 0;
|
||||
assert.deepStrictEqual(tarProbeAllRows(), [
|
||||
{ tar: '000000', label: 'ISD', doc: 'MSL 16 \u00b7 Keyset 1' },
|
||||
{ tar: 'AF4D02', label: 'My applet', doc: 'MSL 0A' },
|
||||
]);
|
||||
// no preset: no rows
|
||||
globalThis.tarPresetIdx = () => -1;
|
||||
assert.deepStrictEqual(tarProbeAllRows(), []);
|
||||
delete globalThis.tarPresetIdx;
|
||||
});
|
||||
|
||||
test('tarProbeChecked reads the checked boxes in list order', () => {
|
||||
const boxes = [
|
||||
{ value: '000000', checked: true },
|
||||
{ value: 'B00000', checked: false },
|
||||
{ value: 'b00120', checked: true },
|
||||
];
|
||||
globalThis.document = { querySelectorAll: () => boxes };
|
||||
assert.deepStrictEqual(tarProbeChecked(), ['000000', 'B00120']);
|
||||
boxes.forEach(b => { b.checked = false; });
|
||||
assert.deepStrictEqual(tarProbeChecked(), []);
|
||||
});
|
||||
|
||||
test('tarProbeVerdictLabel names every verdict', () => {
|
||||
assert.strictEqual(tarProbeVerdictLabel('registered'), 'registered');
|
||||
assert.strictEqual(tarProbeVerdictLabel('no_answer'), 'accepted, no answer');
|
||||
assert.strictEqual(tarProbeVerdictLabel('no_por'), 'accepted, no PoR');
|
||||
assert.strictEqual(tarProbeVerdictLabel('refused'), 'refused');
|
||||
assert.strictEqual(tarProbeVerdictLabel('something'), 'something');
|
||||
});
|
||||
|
||||
test('tarProbeHtml renders the summary and one row per TAR', () => {
|
||||
const out = tarProbeHtml({
|
||||
success: true, registered: 2, total: 4, final_cntr: '0000000187',
|
||||
results: [
|
||||
{ tar: '000000', label: 'ISD', verdict: 'registered', sw: '9000', por_sw: '6D00', por_data: '' },
|
||||
{ tar: 'B00000', label: 'RFM', verdict: 'registered', sw: '9000', por_sw: '6B00', por_data: '3F00' },
|
||||
{ tar: 'B00001', label: 'ADF', verdict: 'no_por', sw: '9000' },
|
||||
{ tar: 'B00200', label: 'RFU', verdict: 'refused', sw: '6200' },
|
||||
],
|
||||
});
|
||||
assert.match(out, /Registered applications: <b>2 \/ 4<\/b>/);
|
||||
assert.match(out, /counter 0000000187/);
|
||||
assert.match(out, /text-emerald-700 dark:text-emerald-400">registered/);
|
||||
assert.match(out, /text-amber-600 dark:text-amber-400">accepted, no PoR/);
|
||||
assert.match(out, /text-red-600 dark:text-red-400">refused/);
|
||||
assert.ok(out.includes('6D00') && out.includes('6200'));
|
||||
assert.strictEqual(tarProbeHtml(null), '');
|
||||
});
|
||||
|
||||
test('the SCP80 tab wires the TAR probe pill', () => {
|
||||
assert.match(html, /data-scp80-sub="tar" onclick="scp80SwitchSubtab\('tar'\)" data-l10n="TAR probe"/);
|
||||
assert.match(html, /<div id="scp80-sub-tar" class="hidden">/);
|
||||
assert.ok(html.includes("document.getElementById('scp80-sub-tar').classList.toggle('hidden', name !== 'tar');"));
|
||||
assert.ok(html.includes("tar: 'tar-probe'"), 'the help anchor points at the new section');
|
||||
// its own preset selector and the warning banner; v3.26.0: the probe
|
||||
// checks the preset's TAR list - no keyset selector, no custom TARs
|
||||
assert.match(html, /id="tar-card-sel" onchange="tarApplyCard\(this.value\)"/);
|
||||
assert.match(html, /id="tar-preset-warning"/);
|
||||
assert.ok(!html.includes('id="tar-keyset-sel"'), 'the probe keyset selector is gone');
|
||||
assert.ok(!html.includes('tarProbeAddCustom'), 'no arbitrary TARs anymore');
|
||||
assert.match(html, /TARs to probe \(the preset's TAR list\)/);
|
||||
// the checklist, the APDU and the Execute button
|
||||
assert.match(html, /id="tar-list"/);
|
||||
assert.match(html, /onclick="tarProbeSelectAll\(true\)"/);
|
||||
assert.match(html, /id="tar-apdu"[^>]*value="00A40000023F00"/);
|
||||
assert.match(html, /onclick="tarExecute\(\)"/);
|
||||
assert.match(html, /id="tar-result"/);
|
||||
// the run: the preset and the checked TARs travel - the server resolves
|
||||
// each row's keyset
|
||||
assert.ok(html.includes("pysimFetch('/api/tar-probe', body)"));
|
||||
assert.ok(html.includes('tars: tars'));
|
||||
assert.ok(!html.includes("getRamSpParams('tar-card-sel')"));
|
||||
assert.ok(html.includes('if (data.final_cntr) ramSaveCntr(data.final_cntr);'));
|
||||
// the RAM-era leftovers are gone
|
||||
assert.ok(!html.includes('id="ram-tar-rows"'));
|
||||
assert.ok(!html.includes('id="ram-tar-list"'));
|
||||
assert.ok(!html.includes("op === 'tar-probe'"));
|
||||
assert.ok(!html.includes('function ramTarProbe'));
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const f of ['tpNorm', 'tpValid', 'tpGetBit', 'tpSetBit', 'tpBitLabel', 'tpLayoutGroups',
|
||||
'tpValueFieldFor', 'tpValueWidth', 'tpGetValue', 'tpSetValue']) {
|
||||
code += extractFunc(html, f) + '\n';
|
||||
}
|
||||
code += html.match(/const TP_BITS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const TP_LAYOUT = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const TP_PRESETS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const TP_VALUE_FIELDS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n';
|
||||
eval(code);
|
||||
|
||||
test('tpNorm / tpValid normalize and validate profile hex', () => {
|
||||
assert.strictEqual(tpNorm(' ff ee 00 '), 'FFEE00');
|
||||
assert.strictEqual(tpNorm('zz'), '');
|
||||
assert.ok(tpValid('FF'));
|
||||
assert.ok(tpValid('00FF'));
|
||||
assert.ok(!tpValid(''));
|
||||
assert.ok(!tpValid('F'));
|
||||
});
|
||||
|
||||
test('tpGetBit / tpSetBit address bits LSB-first (spec b1..b8 order)', () => {
|
||||
// TS 102 223 5.2 tables list b1 (LSB) first: index 0 = byte 1 b1 = 0x01
|
||||
const hex = '01' + '80'; // byte 1 b1 set, byte 2 b8 set
|
||||
assert.strictEqual(tpGetBit(hex, 0), true); // byte 1 b1
|
||||
assert.strictEqual(tpGetBit(hex, 7), false); // byte 1 b8
|
||||
assert.strictEqual(tpGetBit(hex, 8), false); // byte 2 b1
|
||||
assert.strictEqual(tpGetBit(hex, 15), true); // byte 2 b8
|
||||
assert.strictEqual(tpGetBit(hex, 16), null); // beyond the profile
|
||||
// toggling keeps the other bits untouched
|
||||
assert.strictEqual(tpSetBit('00', 0, true), '01');
|
||||
assert.strictEqual(tpSetBit('01', 0, false), '00');
|
||||
assert.strictEqual(tpSetBit('FF', 7, false), '7F');
|
||||
// a bit beyond the current length grows the profile with zero bytes
|
||||
assert.strictEqual(tpSetBit('FF', 24, true), 'FF000001');
|
||||
// the project default really is BIP-capable under this mapping
|
||||
const mi = 'FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003';
|
||||
assert.strictEqual(tpGetBit(mi, 88), true); // byte 12 b1 = OPEN CHANNEL
|
||||
assert.strictEqual(tpGetBit(mi, 91), true); // byte 12 b4 = SEND DATA
|
||||
assert.strictEqual(tpGetBit(mi, 134), true); // byte 17 b7 = E-UTRAN
|
||||
assert.strictEqual(tpGetBit(mi, 135), true); // byte 17 b8 = HSDPA
|
||||
});
|
||||
|
||||
test('TERMINAL PROFILE bit table matches the spec spot checks', () => {
|
||||
assert.ok(TP_BITS.length >= 312, 'expected the full byte 1..39 table');
|
||||
assert.strictEqual(TP_BITS.length % 8, 0);
|
||||
assert.strictEqual(TP_BITS[0], 'Profile download'); // byte 1 b1
|
||||
assert.strictEqual(TP_BITS[16], 'Proactive UICC: DISPLAY TEXT'); // byte 3 b1
|
||||
assert.strictEqual(TP_BITS[32], 'Proactive UICC: SET UP EVENT LIST'); // byte 5 b1
|
||||
assert.strictEqual(TP_BITS[42], 'Event: Data available'); // byte 6 b3
|
||||
assert.strictEqual(TP_BITS[50], 'Proactive UICC: PERFORM CARD APDU'); // byte 7 b3 (pySim said RESET)
|
||||
assert.strictEqual(TP_BITS[88], 'Proactive UICC: OPEN CHANNEL'); // byte 12 b1
|
||||
assert.strictEqual(TP_BITS[140], 'Proactive UICC: PROVIDE LOCAL INFORMATION (ESN)'); // byte 18 b5
|
||||
assert.strictEqual(TP_BITS[181], 'Proactive UICC: PROVIDE LOCAL INFORMATION (MEID)'); // byte 23 b6
|
||||
assert.strictEqual(TP_BITS[260], 'Proactive UICC: PROVIDE LOCAL INFORMATION (Supported Radio Access Technologies)'); // byte 33 b5
|
||||
assert.strictEqual(TP_BITS[280], 'Data Connection Status Change Event support – PDU Connection'); // byte 36 b1
|
||||
assert.strictEqual(tpBitLabel(0), 'Profile download');
|
||||
assert.match(tpBitLabel(400), /^RFU \(byte 51 b1\)/); // beyond the table
|
||||
});
|
||||
|
||||
test('value-field rows carry the spec labels (soft keys, channels, screen, ND/NK, frames)', () => {
|
||||
const byteLabels = b => TP_BITS.slice((b - 1) * 8, b * 8);
|
||||
// byte 11: soft keys value (0xFF reserved)
|
||||
assert.match(byteLabels(11)[0], /Maximum number of soft keys/);
|
||||
assert.match(byteLabels(11)[0], /FF reserved/);
|
||||
assert.strictEqual(byteLabels(11)[7], byteLabels(11)[0]);
|
||||
// byte 13 b6..b8: BIP channel count
|
||||
assert.strictEqual(byteLabels(13)[5], 'Number of BIP channels supported (value b6..b8)');
|
||||
assert.strictEqual(byteLabels(13)[7], byteLabels(13)[5]);
|
||||
// byte 14: height b1..b5, ND b6, NK b7, sizing b8
|
||||
assert.match(byteLabels(14)[0], /Screen height/);
|
||||
assert.strictEqual(byteLabels(14)[5], 'No display capability (class ND)');
|
||||
assert.strictEqual(byteLabels(14)[6], 'No keypad available (class NK)');
|
||||
assert.strictEqual(byteLabels(14)[7], 'Screen Sizing Parameters supported');
|
||||
// byte 15: width b1..b7, variable fonts b8
|
||||
assert.match(byteLabels(15)[0], /Screen width/);
|
||||
assert.strictEqual(byteLabels(15)[7], 'Variable size fonts');
|
||||
// byte 16: four effect flags, RFU b5, width reduction b6..b8
|
||||
assert.strictEqual(byteLabels(16)[0], 'Display can be resized');
|
||||
assert.strictEqual(byteLabels(16)[1], 'Text Wrapping supported');
|
||||
assert.strictEqual(byteLabels(16)[2], 'Text Scrolling supported');
|
||||
assert.strictEqual(byteLabels(16)[3], 'Text Attributes supported');
|
||||
assert.strictEqual(byteLabels(16)[4], 'RFU');
|
||||
assert.match(byteLabels(16)[5], /Width reduction when in a menu/);
|
||||
// byte 19 b1..b4: TIA/EIA-136-270 protocol version
|
||||
assert.match(byteLabels(19)[0], /TIA\/EIA-136-270 protocol version/);
|
||||
assert.strictEqual(byteLabels(19)[3], byteLabels(19)[0]);
|
||||
// byte 24 b1..b4: max frames
|
||||
assert.match(byteLabels(24)[0], /Maximum number of frames supported/);
|
||||
assert.strictEqual(byteLabels(24)[3], byteLabels(24)[0]);
|
||||
// every value field matches the TP_VALUE_FIELDS table (no overlap with flags)
|
||||
for (const f of TP_VALUE_FIELDS) {
|
||||
for (let b = f.from; b <= f.to; b++) {
|
||||
assert.match(byteLabels(f.byte)[b - 1], /value|soft keys|Screen|Width|TIA|frames/i,
|
||||
'byte ' + f.byte + ' b' + b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('TP_VALUE_FIELDS decode little-endian bit ranges and round-trip', () => {
|
||||
const f = (byte, from, to) => ({ byte: byte, from: from, to: to });
|
||||
// byte 13 b6..b8: 0xE0 = 7 channels (project default)
|
||||
const mi = 'FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003';
|
||||
const ch = tpValueFieldFor(13);
|
||||
assert.deepStrictEqual({ byte: ch.byte, from: ch.from, to: ch.to }, f(13, 6, 8));
|
||||
assert.strictEqual(tpValueWidth(ch), 3);
|
||||
assert.strictEqual(tpGetValue(mi, ch), 7); // byte 13 = 0xE2
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(11)), 0x02); // soft keys = 2
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(14)), 0x00); // height
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(15)), 0x00); // width
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(16)), 0x00); // width reduction
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(19)), 0x00);
|
||||
assert.strictEqual(tpGetValue(mi, tpValueFieldFor(24)), 0x00);
|
||||
// soft keys 0xFE is the largest documented value (byte 11; 'FF' reserved)
|
||||
const sk = tpValueFieldFor(11);
|
||||
const blank11 = '00'.repeat(11);
|
||||
assert.strictEqual(tpGetValue(blank11, sk), 0x00);
|
||||
assert.strictEqual(tpGetValue(tpSetValue(blank11, sk, 0xFE), sk), 0xFE);
|
||||
assert.strictEqual(tpGetValue(tpSetValue(blank11, sk, 0xFF), sk), 0xFF);
|
||||
assert.strictEqual(tpGetValue('FE', sk), null); // byte 11 missing from a short profile
|
||||
// set: byte 13 b6..b8 = 5 keeps the bearer bits, clamps to 3 bits
|
||||
const base13 = '00'.repeat(12) + 'E2';
|
||||
assert.strictEqual(tpSetValue(base13, ch, 5), '00'.repeat(12) + 'A2'); // 0xE2 & 0x1F | (5<<5)
|
||||
assert.strictEqual(tpSetValue(base13, ch, 8), base13); // clamped to 7
|
||||
assert.strictEqual(tpSetValue('00'.repeat(13), ch, 3), '00'.repeat(12) + '60');
|
||||
// a profile shorter than the field's byte grows with zero bytes (like tpSetBit)
|
||||
assert.strictEqual(tpSetValue('', ch, 3), '00'.repeat(12) + '60');
|
||||
// round-trip every field through get/set
|
||||
for (const vf of TP_VALUE_FIELDS) {
|
||||
const max = (1 << tpValueWidth(vf)) - 1;
|
||||
const lo = tpSetValue('0000000000000000000000000000000000000000000000000000000000000000', vf, 1);
|
||||
assert.strictEqual(tpGetValue(lo, vf), 1, 'field byte ' + vf.byte);
|
||||
const hi = tpSetValue(lo, vf, max);
|
||||
assert.strictEqual(tpGetValue(hi, vf), max, 'field byte ' + vf.byte);
|
||||
assert.strictEqual(tpSetValue(hi, vf, 0), '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'field byte ' + vf.byte + ' clears');
|
||||
}
|
||||
// out-of-range values clamp, garbage reads as 0
|
||||
assert.strictEqual(tpGetValue('', tpValueFieldFor(11)), null); // byte missing
|
||||
assert.strictEqual(tpGetValue('FF', tpValueFieldFor(13)), null);
|
||||
});
|
||||
|
||||
test('the Configure dialog renders number inputs for value fields', () => {
|
||||
const fn = extractFunc(html, 'tpRenderForm');
|
||||
assert.match(fn, /tpValueFieldFor\(bi \+ 1\)/);
|
||||
assert.match(fn, /type="number"/);
|
||||
assert.match(fn, /tpValueInput\(/);
|
||||
// the soft-keys hint stays visible in the UI
|
||||
assert.match(html, /FF' is reserved for future use/);
|
||||
const handler = extractFunc(html, 'tpValueInput');
|
||||
assert.match(handler, /tpSetValue/);
|
||||
assert.match(handler, /tpSyncPreset/);
|
||||
});
|
||||
|
||||
test('3GPP-defined bits use the TS 31.111 names, not placeholders', () => {
|
||||
assert.ok(!TP_BITS.some(l => /reserved by 3gpp/i.test(l)), 'no "reserved by 3GPP" labels');
|
||||
assert.ok(!TP_BITS.some(l => /reserved by etsi/i.test(l)));
|
||||
// a few audited 3GPP bits (TS 31.111 5.2); byteLabels[0] is b1
|
||||
const byteLabels = b => TP_BITS.slice((b - 1) * 8, b * 8);
|
||||
assert.strictEqual(byteLabels(17)[6], 'E-UTRAN'); // byte 17 b7
|
||||
assert.strictEqual(byteLabels(17)[7], 'HSDPA'); // byte 17 b8
|
||||
assert.strictEqual(byteLabels(18)[5], 'CALL CONTROL on GPRS'); // byte 18 b6
|
||||
assert.strictEqual(byteLabels(25)[4], 'Event: Network Rejection for GERAN/UTRAN'); // byte 25 b5
|
||||
assert.strictEqual(byteLabels(32)[0], 'IMS support'); // byte 32 b1
|
||||
assert.strictEqual(byteLabels(34)[0], 'URI support for SEND SHORT MESSAGE');
|
||||
assert.match(byteLabels(39)[0], /NG-RAN\/Satellite NG-RAN Timing Advance/);
|
||||
});
|
||||
|
||||
test('TERMINAL PROFILE presets are valid even-length hex', () => {
|
||||
assert.ok(TP_PRESETS.length >= 9);
|
||||
// the project default stays first (it matches the CLI default profile)
|
||||
assert.match(TP_PRESETS[0].name, /Xiaomi Mi A1/);
|
||||
const names = TP_PRESETS.map(p => p.name);
|
||||
for (const model of ['Quectel GSM module', 'Samsung S21+ 5G', 'Samsung A55 5G',
|
||||
'Xiaomi Redmi Note 10 LTE', 'Sony Xperia Z5c LTE', 'Huawei E5573c / M150 (LTE)',
|
||||
'Huawei E173 3G modem', 'Nokia 7210 2G']) {
|
||||
assert.ok(names.some(n => n.includes(model)), model);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const p of TP_PRESETS) {
|
||||
assert.ok(p.name && p.profile, p.name);
|
||||
assert.ok(/^[0-9A-F]+$/.test(p.profile) && p.profile.length % 2 === 0, p.name);
|
||||
assert.ok(p.profile.length >= 8 && p.profile.length <= 510, p.name);
|
||||
assert.ok(!seen.has(p.profile), 'duplicate profile: ' + p.name);
|
||||
seen.add(p.profile);
|
||||
}
|
||||
});
|
||||
|
||||
test('Phone tab exposes the TERMINAL PROFILE block and Configure dialog', () => {
|
||||
// the compact block has no room for the hex value: Send + Configure only
|
||||
assert.ok(!html.includes('id="tp-current"'));
|
||||
assert.match(html, /id="tp-send-btn"[^>]*data-needs="card"/);
|
||||
assert.match(html, /id="tp-configure-btn"[^>]*data-needs="server"/);
|
||||
assert.ok(html.includes('id="tp-modal"'));
|
||||
assert.ok(html.includes('id="tp-preset"'));
|
||||
assert.ok(html.includes('id="tp-hex"'));
|
||||
assert.ok(html.includes('id="tp-form"'));
|
||||
assert.ok(html.includes('id="tp-apply-btn"'));
|
||||
// the preset select sits above the hex field, not next to it
|
||||
assert.ok(html.indexOf('id="tp-preset"') < html.indexOf('id="tp-hex"'));
|
||||
// Apply/Cancel sit at the top (right of the preset/hex fields), above the
|
||||
// long bits grid, so they are reachable without scrolling
|
||||
assert.ok(html.indexOf('id="tp-apply-btn"') < html.indexOf('id="tp-form"'));
|
||||
});
|
||||
|
||||
test('tpLayoutGroups lays the byte blocks out in the configured columns', () => {
|
||||
assert.deepStrictEqual(tpLayoutGroups(33), [
|
||||
{ from: 1, to: 12, cols: 2 },
|
||||
{ from: 13, to: 16, cols: 4 },
|
||||
{ from: 17, to: 18, cols: 2 },
|
||||
{ from: 19, to: 21, cols: 3 },
|
||||
{ from: 22, to: 25, cols: 2 },
|
||||
{ from: 26, to: 28, cols: 3 },
|
||||
{ from: 29, to: 30, cols: 2 },
|
||||
{ from: 31, to: 33, cols: 1 },
|
||||
]);
|
||||
// short profiles clamp: only existing bytes get a group
|
||||
assert.deepStrictEqual(tpLayoutGroups(8), [{ from: 1, to: 8, cols: 2 }]);
|
||||
assert.deepStrictEqual(tpLayoutGroups(18), [
|
||||
{ from: 1, to: 12, cols: 2 },
|
||||
{ from: 13, to: 16, cols: 4 },
|
||||
{ from: 17, to: 18, cols: 2 },
|
||||
]);
|
||||
// long profiles: everything past byte 30 is one per row
|
||||
assert.deepStrictEqual(tpLayoutGroups(40).slice(-1), [{ from: 31, to: 40, cols: 1 }]);
|
||||
});
|
||||
@@ -0,0 +1,859 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_NAMES = {', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
|
||||
// the event name aliases (the server's EVENT_NAMES keywords) + the resolver
|
||||
eval(extractBlock('const EVENT_ALIASES = {', 'function eventCode').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'eventCode'));
|
||||
// the builders' pure helpers (eventFormFieldsHtml/Values are the shared form)
|
||||
eval(extractFunc(html, 'bytesToHex'));
|
||||
eval(extractFunc(html, 'eventAddressTlv'));
|
||||
eval(extractFunc(html, 'encPlmn'));
|
||||
eval(extractFunc(html, 'eventFormFieldsHtml'));
|
||||
eval(extractFunc(html, 'eventFormValues'));
|
||||
eval(extractFunc(html, 'eventFormPatternError'));
|
||||
eval(extractFunc(html, 'eventFormSrc'));
|
||||
eval(html.match(/const EVENT_INNER_MAX = \d+;/)[0].replace('const ', 'var '));
|
||||
eval(extractFunc(html, 'eventEnvelopeSize'));
|
||||
eval(extractFunc(html, 'eventFitsOneEnvelope'));
|
||||
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'testTemplate'));
|
||||
eval(extractFunc(html, 'testMenuSelectProblem'));
|
||||
eval(extractFunc(html, 'testScriptProblem'));
|
||||
eval(extractFunc(html, 'testPorSpecSummary'));
|
||||
eval(extractFunc(html, 'testCheckSummary'));
|
||||
eval(extractFunc(html, 'testStepSummary'));
|
||||
eval(extractFunc(html, 'testCommandOptions'));
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => String(s);
|
||||
globalThis.testScripts = () => _testScripts || [];
|
||||
globalThis.testSuites = () => _testSuites || [];
|
||||
eval(extractFunc(html, 'testFormRow'));
|
||||
eval(extractFunc(html, 'testFormInput'));
|
||||
eval(extractFunc(html, 'testFormSelect'));
|
||||
// the TERMINAL RESPONSE result tables + the shared result-row helpers
|
||||
eval(extractBlock('const TR_RESULTS = [', 'const TR_RESULT_ADD_INFO = [').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_ADD_INFO = [', '// TS 102 223 v18.3.0 Tables 6.1/6.2').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_BY_COMMAND = {', 'const TR_RESULT_ALIASES = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_ALIASES = {', 'function trCommandCode').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'trCommandCode'));
|
||||
eval(extractFunc(html, 'trResultOptions'));
|
||||
eval(extractFunc(html, 'trResultFormValue'));
|
||||
eval(extractFunc(html, 'trResultValid'));
|
||||
eval(extractFunc(html, 'trResultCollect'));
|
||||
eval(extractFunc(html, 'testTrResultChanged'));
|
||||
eval(extractFunc(html, 'trResultRowHtml'));
|
||||
eval(extractFunc(html, 'testStepRender'));
|
||||
eval(extractFunc(html, 'testRenderChecks'));
|
||||
eval(extractFunc(html, 'testStepCollect'));
|
||||
eval(extractFunc(html, 'testStepFormError'));
|
||||
eval(extractFunc(html, 'testWriteBackCounter'));
|
||||
eval(extractFunc(html, 'testRunPreset'));
|
||||
eval(extractFunc(html, 'testScriptsWriteback', true));
|
||||
eval(extractFunc(html, 'testScriptsWritebackQueued'));
|
||||
eval(extractFunc(html, 'testScriptsSave'));
|
||||
eval(extractFunc(html, 'testRunStart', true));
|
||||
eval(extractFunc(html, 'testSuiteDeleteScript', true));
|
||||
eval(extractFunc(html, 'testSuitesRefetch', true));
|
||||
eval(extractFunc(html, 'testChecksCollect'));
|
||||
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
||||
+ ' var _testScripts = null; var _testSuites = []; var _testScriptId = null;'
|
||||
+ ' var _testScriptDraft = null; var _testRunState = null; var _testSaveTimer = null;'
|
||||
+ ' var _testWriteChains = new WeakMap(); var _testLastPresetIdx = -1;'
|
||||
+ ' var _testRunScriptRef = null; var _testSuiteIdx = -1;');
|
||||
// The dialog sweep replaced the native alert/confirm with the helpers: route
|
||||
// them to the native stubs each test installs on globalThis.
|
||||
globalThis.pysimAlert = m => globalThis.alert(m);
|
||||
globalThis.pysimConfirm = async m => globalThis.confirm(m);
|
||||
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||
|
||||
function fakeForm(values) {
|
||||
globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) };
|
||||
}
|
||||
|
||||
test('the templates contain usable steps', () => {
|
||||
const menu = testTemplate('menu');
|
||||
assert.strictEqual(menu.steps.length, 3);
|
||||
assert.strictEqual(menu.steps[0].kind, 'menu-select');
|
||||
assert.deepStrictEqual(menu.steps[0].check.sw, { mode: 'mask', value: '91??' });
|
||||
assert.strictEqual(menu.steps[1].command, 'SELECT ITEM');
|
||||
assert.strictEqual(menu.steps[1].checks[0].kind, 'item');
|
||||
assert.strictEqual(menu.steps[2].command, 'DISPLAY TEXT');
|
||||
const scp80 = testTemplate('scp80');
|
||||
assert.strictEqual(scp80.steps[0].kind, 'scp80');
|
||||
assert.strictEqual(scp80.steps[0].check.por, 'none');
|
||||
assert.ok(scp80.steps[0].params.tar);
|
||||
assert.strictEqual(testTemplate('empty').steps.length, 0);
|
||||
});
|
||||
|
||||
test('testScriptProblem accepts good scripts and names bad ones', () => {
|
||||
assert.strictEqual(testScriptProblem(testTemplate('menu')), '');
|
||||
assert.strictEqual(testScriptProblem(testTemplate('scp80')), '');
|
||||
assert.match(testScriptProblem(null), /object/);
|
||||
assert.match(testScriptProblem({ steps: [] }), /no steps/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'nope' }] }), /action or an expectation/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'nope' }] }), /unknown action kind/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'apdu', params: {} }] }), /APDU is empty/);
|
||||
assert.match(testScriptProblem({
|
||||
steps: [{ type: 'action', kind: 'scp80', params: { apdu: '' } }] }), /SCP80/);
|
||||
assert.match(testScriptProblem({
|
||||
steps: [{ type: 'action', kind: 'menu-select', params: { item_id: 0 } }] }), /item id/);
|
||||
// the item can be selected by its text instead of the install-dependent id
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'contains' } }] }), '');
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: {} }] }), /item id or text/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: {} }] }), /event is required/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: { event: '03', fields: [] } }] }), /object/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: { event: '00' } }] }), /event data is required/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: { event: '00', data: 'ZZ' } }] }), /hex/);
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: { event: '03', fields: { status: 0 } } }] }), '');
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||
params: { event: '00', data: '1C0101', src: '83' } }] }), '');
|
||||
assert.match(testScriptProblem({
|
||||
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'proactive-drain',
|
||||
params: { attempts: 0 } }] }), /attempts/);
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'proactive-drain',
|
||||
params: {} }] }), '');
|
||||
// the selected SCP80 source decides which value is required
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { source: 'apdu', sp: 'AA' } }] }), /SCP80/);
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { source: 'apdu', apdu: '80E2' } }] }), '');
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { source: 'sp', sp: 'AA', apdu: '80E2' } }] }), '');
|
||||
});
|
||||
|
||||
test('the proactive-drain step collects its fields', () => {
|
||||
_testEditStep = { type: 'action', kind: 'proactive-drain', params: {} };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'proactive-drain', 'test-f-presult': 'ok', 'test-f-pfirst': 'cancel',
|
||||
'test-f-pattempts': '5', 'test-f-pinterval': '0', 'test-f-prequire': 'refresh',
|
||||
'test-f-prequal': '00', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
const p = _testEditStep.params;
|
||||
assert.strictEqual(p.respond.result, 'ok');
|
||||
assert.strictEqual(p.first.result, 'cancel');
|
||||
assert.strictEqual(p.attempts, 5);
|
||||
assert.strictEqual(p.interval_ms, 0);
|
||||
assert.deepStrictEqual(p.require, { command: 'REFRESH', qualifier: '00' });
|
||||
// an empty require field drops the assertion
|
||||
fakeForm({ 'test-step-kind': 'proactive-drain', 'test-f-presult': 'ok', 'test-f-pfirst': '',
|
||||
'test-f-pattempts': '3', 'test-f-pinterval': '200', 'test-f-prequire': '',
|
||||
'test-f-prequal': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.ok(!('first' in _testEditStep.params));
|
||||
assert.ok(!('require' in _testEditStep.params));
|
||||
});
|
||||
|
||||
test('testStepSummary renders actions', () => {
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'menu-select', params: { item_id: 3 },
|
||||
check: { sw: { mode: 'mask', value: '91??' } } }),
|
||||
'ENVELOPE(Menu Selection) item=3 · SW ~91??');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'exact' } }),
|
||||
'ENVELOPE(Menu Selection) text ="My menu"');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'apdu', params: { apdu: '00A4' } }),
|
||||
'APDU 00A4');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'scp80', params: { apdu: '80E2', tar: 'B00000' },
|
||||
check: { sw: { mode: 'mask', value: '91??' }, por: 'none' } }),
|
||||
'SCP80 80E2 TAR=B00000 · SW ~91??, PoR none');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'status', params: { attempts: 5 } }),
|
||||
'STATUS x5 (poll)');
|
||||
// the event action's value may be a name keyword (the server's alias
|
||||
// form), the editor's hex byte or a number - all show the event name
|
||||
for (const ev of ['access_tech', '0B', '0b', 11, 0x0B]) {
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'event', params: { event: ev } }),
|
||||
'EVENT Access technology change (single access technology)', String(ev));
|
||||
}
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'event', params: { event: 'data_connection' },
|
||||
check: { sw: { mode: 'mask', value: '91??' } } }),
|
||||
'EVENT Data connection status change · SW ~91??');
|
||||
// a value that is neither a code nor a known name is shown as-is
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'event', params: { event: 'nonsense' } }),
|
||||
'EVENT nonsense');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'event', params: { event: '1F' } }),
|
||||
'EVENT Slices status change');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'proactive-drain',
|
||||
params: { attempts: 5, respond: { result: 'ok' },
|
||||
require: { command: 'REFRESH', qualifier: '00' } } }),
|
||||
'DRAIN x5 require REFRESH q=00 TR ok');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'proactive-drain',
|
||||
params: { attempts: 3, first: { result: 'cancel' } } }),
|
||||
'DRAIN x3 TR ok first cancel');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'action', kind: 'file-read', params: { path: 'MF/2FE2' } }),
|
||||
'READ MF/2FE2');
|
||||
assert.ok(testStepSummary({ type: 'action', kind: 'status', params: {},
|
||||
check: { sw: { mode: 'exact', value: '9000' } }, on_fail: 'warning' }).includes('⚠'));
|
||||
});
|
||||
|
||||
test('testStepSummary renders expectations with checks and the response', () => {
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'expect', command: 'SELECT ITEM',
|
||||
checks: [{ kind: 'item', id: 1, text: 'test', mode: 'contains' }],
|
||||
respond: { result: 'ok', item_id: 1 } }),
|
||||
'EXPECT SELECT ITEM · item #1 ~"test" · TR item=1');
|
||||
assert.strictEqual(
|
||||
testStepSummary({ type: 'expect', command: 'DISPLAY TEXT',
|
||||
checks: [{ kind: 'text', mode: 'contains', value: 'hello' }], respond: { result: 'ok' } }),
|
||||
'EXPECT DISPLAY TEXT · text ~"hello" · TR ok');
|
||||
assert.ok(testStepSummary({ type: 'expect', command: 'DISPLAY TEXT',
|
||||
checks: [{ kind: 'text', value: 'x' }], respond: { result: 'cancel' } }).includes('TR cancel'));
|
||||
assert.ok(testStepSummary({ type: 'expect', command: 'GET INPUT', respond: { result: 'ok', text: 'hi' } })
|
||||
.includes('text="hi"'));
|
||||
assert.strictEqual(testCheckSummary({ kind: 'raw', mode: 'mask', value: 'AA??' }), 'raw ~AA??');
|
||||
});
|
||||
|
||||
test('testCommandOptions covers the proactive names and keeps custom values', () => {
|
||||
const opts = testCommandOptions('DISPLAY TEXT');
|
||||
assert.ok(opts.some(o => o.v === 'ANY'));
|
||||
assert.ok(opts.some(o => o.v === 'DISPLAY TEXT'));
|
||||
assert.ok(opts.some(o => o.v === 'SELECT ITEM'));
|
||||
const custom = testCommandOptions('0x74');
|
||||
assert.ok(custom.some(o => o.v === '0x74'));
|
||||
});
|
||||
|
||||
test('the SCP80 source switch sticks and preserves the other value', () => {
|
||||
_testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
// switch to the pre-built packet: the packet field is not rendered yet
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-apdu': '80E2900000',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.source, 'sp');
|
||||
assert.strictEqual(_testEditStep.params.sp, '');
|
||||
assert.strictEqual(_testEditStep.params.apdu, '80E2900000'); // preserved
|
||||
assert.ok(!('sw' in _testEditStep.check)); // server default
|
||||
// the packet field is rendered now and gets a value
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'sp', 'test-f-sp': 'aabbcc',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||
assert.strictEqual(_testEditStep.params.source, 'sp');
|
||||
assert.strictEqual(_testEditStep.check.por, 'none');
|
||||
// switching back keeps both values
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'any', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.source, 'apdu');
|
||||
assert.strictEqual(_testEditStep.params.sp, 'AABBCC');
|
||||
});
|
||||
|
||||
test('the SCP80 step collects the format and the custom PoR object', () => {
|
||||
_testEditStep = { type: 'action', kind: 'scp80', params: { apdu: '80E2900000' } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||
'test-f-fmt': 'expanded', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
|
||||
'test-f-porstatus': 'por_ok', 'test-f-por-sw': '6a8?', 'test-f-por-data': 'aa??',
|
||||
'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.format, 'expanded');
|
||||
assert.deepStrictEqual(_testEditStep.check.por,
|
||||
{ status: 'por_ok', sw: { mode: 'mask', value: '6A8?' },
|
||||
data: { mode: 'mask', value: 'AA??' } });
|
||||
// a custom PoR check with no fields refuses to save
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||
'test-f-fmt': 'compact', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'custom',
|
||||
'test-f-fail': 'error' });
|
||||
assert.match(testStepFormError(), /PoR check/);
|
||||
assert.ok(!('format' in _testEditStep.params)); // compact is the default
|
||||
// the plain string forms stay and their object is dropped on switch
|
||||
fakeForm({ 'test-step-kind': 'scp80', 'test-f-src': 'apdu', 'test-f-apdu': '80E2900000',
|
||||
'test-f-fmt': '', 'test-f-kvn': '', 'test-f-tar': '', 'test-f-spi1': '',
|
||||
'test-f-spi2': '', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-por': 'none',
|
||||
'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.check.por, 'none');
|
||||
// the summary renders the effective format and the object's fields
|
||||
const s = testStepSummary({ type: 'action', kind: 'scp80',
|
||||
params: { apdu: '80E2', format: 'expanded-ae' },
|
||||
check: { por: { status: 'por_ok', sw: { mode: 'mask', value: '6A8?' } } } });
|
||||
assert.ok(s.includes('fmt=expanded-ae'), s);
|
||||
assert.ok(s.includes('PoR status por_ok SW ~6A8?'), s);
|
||||
});
|
||||
|
||||
test('the SCP80 step form renders the format select and the custom PoR fields', () => {
|
||||
const els = {
|
||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-step-title': {}, 'test-step-body': {},
|
||||
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditStep = { type: 'action', kind: 'scp80',
|
||||
params: { apdu: '80E2900000', format: 'expanded' },
|
||||
check: { por: { status: 'por_ok' } } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
testStepRender();
|
||||
const body = els['test-step-body'].innerHTML;
|
||||
assert.match(body, /id="test-f-fmt"/);
|
||||
assert.match(body, /<option value="expanded" selected>/);
|
||||
assert.match(body, /<option value="custom" selected>/);
|
||||
assert.match(body, /id="test-f-porstatus" value="por_ok"/);
|
||||
assert.match(body, /id="test-f-por-sw" value=""/);
|
||||
// the block explains where PoR SW comes from (the applet-TAR confusion)
|
||||
assert.ok(body.includes('R-APDU SW inside a remote-management'), body);
|
||||
assert.ok(body.includes('leave SW empty'), body);
|
||||
// the pre-built packet source has no format select (the wrapper applies
|
||||
// to a C-APDU only)
|
||||
_testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB' } };
|
||||
testStepRender();
|
||||
assert.ok(!els['test-step-body'].innerHTML.includes('id="test-f-fmt"'));
|
||||
});
|
||||
|
||||
test('the alpha and sms checks collect and summarise', () => {
|
||||
assert.strictEqual(testCheckSummary({ kind: 'alpha', mode: 'exact', value: 'Alfa' }),
|
||||
'alpha ="Alfa"');
|
||||
assert.strictEqual(testCheckSummary({ kind: 'sms', da: '12345',
|
||||
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' },
|
||||
udl: 5, ud: { mode: 'mask', value: 'AA??' } }),
|
||||
'sms DA 12345 PID 7F DCS 00 UDL 5 UD ~AA??');
|
||||
// the sms fields collect with hex masks and a decimal UDL (0 included)
|
||||
_testEditChecks = [{ kind: 'sms' }];
|
||||
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-smsda-0': '12345',
|
||||
'test-check-smspid-0': '7f', 'test-check-smsdcs-0': '00',
|
||||
'test-check-smsudl-0': '0', 'test-check-smsud-0': 'aa??',
|
||||
'test-check-fail-0': 'error' });
|
||||
testChecksCollect();
|
||||
assert.deepStrictEqual(_testEditChecks, [{ kind: 'sms', on_fail: 'error', da: '12345',
|
||||
pid: { mode: 'exact', value: '7F' }, dcs: { mode: 'exact', value: '00' }, udl: 0,
|
||||
ud: { mode: 'mask', value: 'AA??' } }]);
|
||||
// an empty sms check is dropped on save; an alpha with a value survives
|
||||
_testEditStep = { type: 'expect', command: 'SEND SHORT MESSAGE', checks: [],
|
||||
respond: { result: 'ok' } };
|
||||
_testEditChecks = [{ kind: 'sms' }, { kind: 'alpha', mode: 'contains', value: 'Alfa' }];
|
||||
fakeForm({ 'test-check-kind-0': 'sms', 'test-check-fail-0': 'error',
|
||||
'test-check-kind-1': 'alpha', 'test-check-mode-1': 'contains',
|
||||
'test-check-value-1': 'Alfa', 'test-check-fail-1': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.checks,
|
||||
[{ kind: 'alpha', on_fail: 'error', mode: 'contains', value: 'Alfa' }]);
|
||||
});
|
||||
|
||||
test('the menu-select editor collects the item id or the text', () => {
|
||||
// by text (the id varies with the applet's install parameters)
|
||||
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
|
||||
'test-f-itemmode': 'contains', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { text: 'My menu', mode: 'contains' });
|
||||
// by id when no text is given
|
||||
_testEditStep = { type: 'action', kind: 'menu-select', params: {} };
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '3', 'test-f-itemtext': '',
|
||||
'test-f-itemmode': 'exact', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
|
||||
});
|
||||
|
||||
test('the menu-select editor authors the case-sensitive match', () => {
|
||||
// the select stores the flag only when set (the server default is
|
||||
// case-insensitive), so a step can keep an exact-case match
|
||||
_testEditStep = { type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'contains' } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
|
||||
'test-f-itemmode': 'contains', 'test-f-itemcase': 'true',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params,
|
||||
{ text: 'My menu', mode: 'contains', case_sensitive: true });
|
||||
// the default is omitted from the stored params
|
||||
_testEditStep = { type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', mode: 'contains' } };
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
|
||||
'test-f-itemmode': 'contains', 'test-f-itemcase': 'false',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { text: 'My menu', mode: 'contains' });
|
||||
// the summary flags it
|
||||
assert.ok(testStepSummary({ type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', case_sensitive: true } }).includes(' (case)'));
|
||||
// switching to the item id drops it (there is no text match then)
|
||||
_testEditStep = { type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', case_sensitive: true } };
|
||||
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '3', 'test-f-itemtext': '',
|
||||
'test-f-itemmode': 'exact', 'test-f-itemcase': 'true',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
|
||||
});
|
||||
|
||||
test('the menu-select step form renders the case select', () => {
|
||||
const els = {
|
||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-step-title': {}, 'test-step-body': {},
|
||||
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditStep = { type: 'action', kind: 'menu-select',
|
||||
params: { text: 'My menu', case_sensitive: true } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
testStepRender();
|
||||
const body = els['test-step-body'].innerHTML;
|
||||
assert.match(body, /id="test-f-itemcase"/);
|
||||
assert.match(body, /<option value="true" selected>/);
|
||||
// the default selection is case-insensitive
|
||||
_testEditStep = { type: 'action', kind: 'menu-select', params: { text: 'My menu' } };
|
||||
testStepRender();
|
||||
assert.match(els['test-step-body'].innerHTML, /<option value="false" selected>/);
|
||||
});
|
||||
|
||||
test('the envelope step collects the source override', () => {
|
||||
_testEditStep = { type: 'action', kind: 'envelope', params: {} };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
fakeForm({ 'test-step-kind': 'envelope', 'test-f-event': '3', 'test-f-data': '9B0100',
|
||||
'test-f-eventsrc': '83', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { event: 3, data: '9B0100', src: '83' });
|
||||
});
|
||||
|
||||
test('the event step collects the form values, the built data and the source', () => {
|
||||
_testEditStep = { type: 'action', kind: 'event', params: {} };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
// a server-built event: the fields travel for the server's own build
|
||||
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
|
||||
'test-f-ev-status': '0', 'test-f-ev-mcc': '250', 'test-f-ev-mnc': '01',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.event, '03');
|
||||
assert.strictEqual(_testEditStep.params.fields.status, '0');
|
||||
assert.strictEqual(_testEditStep.params.fields.mcc, '250');
|
||||
assert.match(_testEditStep.params.data, /^[0-9A-F]+$/);
|
||||
assert.strictEqual(testStepFormError(), '');
|
||||
// an event without a server builder (MT call): the built hex + its source
|
||||
_testEditStep = { type: 'action', kind: 'event', params: {} };
|
||||
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': '01',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.event, '00');
|
||||
assert.strictEqual(_testEditStep.params.data, '1C0101');
|
||||
assert.strictEqual(_testEditStep.params.src, '83');
|
||||
assert.strictEqual(testStepFormError(), '');
|
||||
// a srcField event takes the source from the form (call disconnected)
|
||||
_testEditStep = { type: 'action', kind: 'event', params: {} };
|
||||
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '02', 'test-f-ev-src': '83',
|
||||
'test-f-ev-ti': '00', 'test-f-ev-cause_mode': 'rlt',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.src, '83');
|
||||
assert.strictEqual(_testEditStep.params.data, '1C01009A00');
|
||||
// a bad field pattern refuses the save; an empty build does too
|
||||
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': 'ZZZ',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
assert.match(testStepFormError(), /Invalid/);
|
||||
// the summary names the event and its source; a legacy numeric event is
|
||||
// the code itself (the editor stores the hex string form)
|
||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||
params: { event: '03', fields: {} } }), 'EVENT Location status');
|
||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||
params: { event: '00', data: '1C0101', src: '83' } }),
|
||||
'EVENT MT call src=83');
|
||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||
params: { event: 10 } }), 'EVENT ' + EVENT_NAMES[10]);
|
||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||
params: { event: '0A' } }), 'EVENT ' + EVENT_NAMES[10]);
|
||||
});
|
||||
|
||||
test('the shared event-form helpers render, prefill and collect', () => {
|
||||
const cfg = EVENT_FORMS[0x00];
|
||||
const htmlOut = eventFormFieldsHtml(cfg, cfg.fields, {ti: 'AB', ton: 1}, 'x-', false);
|
||||
assert.match(htmlOut, /id="x-ti"/);
|
||||
assert.match(htmlOut, /value="AB"/);
|
||||
assert.match(htmlOut, /id="x-ton"/);
|
||||
assert.match(htmlOut, /value="1" selected/);
|
||||
// a srcField event reports its source; a fixed-source event its own
|
||||
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x00], {}), '83');
|
||||
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x02], {src: '82'}), '82');
|
||||
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x03], {}), '');
|
||||
// the pattern check names the first offending field
|
||||
assert.strictEqual(eventFormPatternError(cfg, {ti: 'AB'}), '');
|
||||
assert.strictEqual(eventFormPatternError(cfg, {ti: 'ZZZ'}), 'Transaction identifier');
|
||||
});
|
||||
|
||||
test('two quick saves of a draft create one script, then update it', async () => {
|
||||
// the per-script write chain serializes saves: without it both would see
|
||||
// the draft's missing id and create two server-side scripts
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => {
|
||||
calls.push(path);
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
return { ok: true, script: { id: 'd'.repeat(32) } };
|
||||
};
|
||||
globalThis.ioStatus = () => {};
|
||||
_testScripts = [{ name: 'one', steps: [{ type: 'action', kind: 'status', params: { attempts: 1 } }] }];
|
||||
_testScriptId = null;
|
||||
globalThis.testCurrentScript = () => _testScripts[0];
|
||||
testScriptsSave(true);
|
||||
testScriptsSave(true);
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
// the create is followed by a suites refetch (the suite gained the member)
|
||||
assert.deepStrictEqual(calls, ['/api/test/scripts', '/api/test/suites',
|
||||
'/api/test/scripts/update']);
|
||||
assert.strictEqual(_testScripts[0].id, 'd'.repeat(32));
|
||||
});
|
||||
|
||||
test('deleting a script cancels a pending debounced save', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => { calls.push(path); return {}; };
|
||||
globalThis.ioStatus = () => {};
|
||||
globalThis.confirm = () => true;
|
||||
globalThis.testRender = () => {};
|
||||
globalThis.testStoresFetch = async () => {};
|
||||
const script = {id: 'e'.repeat(32), name: 'edited',
|
||||
steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]};
|
||||
_testScripts = [script];
|
||||
globalThis.testCurrentScript = () => script;
|
||||
globalThis.testScriptById = () => script;
|
||||
globalThis.testCurrentSuite = () => ({id: 'a'.repeat(32),
|
||||
scripts: [{script_id: script.id, role: 'member', on_fail: 'stop'}]});
|
||||
globalThis.testSuiteEntry = () => ({script_id: script.id});
|
||||
globalThis.testSuites = () => [];
|
||||
testScriptsSave(); // schedules the 600 ms write
|
||||
await testSuiteDeleteScript(0); // the delete path
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
assert.deepStrictEqual(calls, ['/api/test/scripts/delete']);
|
||||
assert.strictEqual(script._deleted, true);
|
||||
});
|
||||
|
||||
test('the STATUS step leaves the SW check to the server default', () => {
|
||||
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.params, { attempts: 5, interval_ms: 200 });
|
||||
assert.deepStrictEqual(_testEditStep.check, {});
|
||||
});
|
||||
|
||||
test('the step form renders the chosen source and no pre-filled SW', () => {
|
||||
const els = {
|
||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-step-title': {}, 'test-step-body': {},
|
||||
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditStep = { type: 'action', kind: 'scp80', params: { source: 'sp', sp: 'AABB', apdu: '80E2' } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
testStepRender();
|
||||
const body = els['test-step-body'].innerHTML;
|
||||
assert.match(body, /id="test-f-sp" value="AABB"/);
|
||||
assert.match(body, /<option value="sp" selected>/);
|
||||
assert.match(body, /id="test-f-sw" value=""/);
|
||||
});
|
||||
|
||||
test('the event step form renders the event select and the Simulator fields', () => {
|
||||
const els = {
|
||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-step-title': {}, 'test-step-body': {},
|
||||
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditStep = { type: 'action', kind: 'event',
|
||||
params: { event: '12', fields: { reg_type: '9', mcc: '250' } } };
|
||||
_testEditChecks = [];
|
||||
_testEditStepIndex = 0;
|
||||
testStepRender();
|
||||
const body = els['test-step-body'].innerHTML;
|
||||
assert.match(body, /id="test-f-evtype"/);
|
||||
assert.match(body, /<option value="12" selected>/);
|
||||
// the event's own form (EVENT_FORMS) with the stored values prefilled and
|
||||
// the built hex preview, not a JSON textarea
|
||||
assert.match(body, /id="test-f-ev-reg_type"/);
|
||||
assert.match(body, /id="test-f-ev-mcc"/);
|
||||
assert.match(body, /value="250"/);
|
||||
assert.match(body, /id="test-f-evdata"/);
|
||||
assert.doesNotMatch(body, /test-f-evfields/);
|
||||
// every modelled event is selectable (the four server-built ones included)
|
||||
assert.match(body, /<option value="00"/);
|
||||
assert.match(body, /<option value="1F"/);
|
||||
});
|
||||
|
||||
test('hex fields strip mask wildcards, check values keep them', () => {
|
||||
_testEditStep = { type: 'action', kind: 'apdu', params: {} };
|
||||
fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2??',
|
||||
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.strictEqual(_testEditStep.params.apdu, '80E2');
|
||||
fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2', 'test-f-sw': '91??',
|
||||
'test-f-sw-mode': 'mask', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
testStepCollect();
|
||||
assert.deepStrictEqual(_testEditStep.check.sw, { mode: 'mask', value: '91??' });
|
||||
});
|
||||
|
||||
test('the counter write-back finds the preset by name after a reload', () => {
|
||||
let renders = 0;
|
||||
globalThis.cards = [{ name: 'Card 1', iccid: '8970119000004600098', cntr: '0000000A' }];
|
||||
globalThis.cardsRender = () => { renders++; };
|
||||
globalThis.ioStatus = () => {};
|
||||
// the name lookup must win: the ICCID helpers are not even called
|
||||
globalThis.cardsNormIccid = () => { throw new Error('ICCID lookup for a name'); };
|
||||
globalThis.cardsFindByIccid = () => { throw new Error('ICCID lookup for a name'); };
|
||||
_testLastPresetIdx = -1;
|
||||
_testRunState = { running: false, scp80_counter: '0000000B', preset: 'Card 1' };
|
||||
testWriteBackCounter();
|
||||
assert.strictEqual(cards[0].cntr, '0000000B');
|
||||
assert.strictEqual(renders, 1);
|
||||
testWriteBackCounter(); // idempotent
|
||||
assert.strictEqual(renders, 1);
|
||||
});
|
||||
|
||||
test('the counter write-back falls back to an ICCID snapshot', () => {
|
||||
let renders = 0;
|
||||
globalThis.cards = [{ name: 'Other', iccid: '8970119000004600098', cntr: '0000000A' }];
|
||||
globalThis.cardsRender = () => { renders++; };
|
||||
globalThis.ioStatus = () => {};
|
||||
globalThis.cardsNormIccid = v => String(v).replace(/\D/g, '');
|
||||
globalThis.cardsFindByIccid = v => (globalThis.cardsNormIccid(v) === '8970119000004600098' ? 0 : -1);
|
||||
_testLastPresetIdx = -1;
|
||||
_testRunState = { running: false, scp80_counter: '0000000C', preset: '8970119000004600098' };
|
||||
testWriteBackCounter();
|
||||
assert.strictEqual(cards[0].cntr, '0000000C');
|
||||
assert.strictEqual(renders, 1);
|
||||
});
|
||||
|
||||
test('the item text check offers contains/exact only', () => {
|
||||
const els = {
|
||||
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-step-title': {}, 'test-step-body': {},
|
||||
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
|
||||
'test-checks': {},
|
||||
};
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditStep = { type: 'expect', command: 'SELECT ITEM',
|
||||
checks: [{ kind: 'item', text: 'test', mode: 'contains' }], respond: { result: 'ok' } };
|
||||
_testEditChecks = [{ kind: 'item', text: 'test', mode: 'contains' }];
|
||||
_testEditStepIndex = 0;
|
||||
testStepRender();
|
||||
const checks = els['test-checks'].innerHTML;
|
||||
assert.match(checks, /<option value="contains" selected>/);
|
||||
assert.ok(!checks.includes('value="mask"'), checks);
|
||||
});
|
||||
|
||||
test('the Simulator hosts the Test script pill and its wiring', () => {
|
||||
assert.match(html, /data-phone-sub="test"[^>]*data-l10n="Test script"/);
|
||||
assert.match(html, /id="phone-sub-test"/);
|
||||
assert.match(html, /\['phone', 'tr', 'bip', 'esim', 'test'\]/);
|
||||
assert.match(html, /else if \(name === 'test'\) \{\s*testInit\(\);/);
|
||||
assert.match(html, /test: 'test-script'/);
|
||||
assert.match(html, /testRunActive\(\) && el\.closest && !el\.closest\('#phone-sub-test'\)/);
|
||||
assert.match(html, /\/api\/test\/run/);
|
||||
assert.match(html, /\/api\/test\/status/);
|
||||
assert.match(html, /id="test-step-modal"/);
|
||||
});
|
||||
|
||||
test('the run sends the stored preset id, not the removed flat fields', () => {
|
||||
const preset = { id: 'p1', name: 'Card 1',
|
||||
keysets: [{ kic: '15', kid: '15', kicKey: 'AA'.repeat(16),
|
||||
kidKey: 'BB'.repeat(16), cntr: '0000000A' }],
|
||||
tars: [{ role: 'isd', tar: '000000', msl: '16' }] };
|
||||
globalThis.cards = [preset];
|
||||
globalThis.cardsMatchedPreset = () => preset;
|
||||
globalThis.cardsScp80Complete = () => true;
|
||||
const found = testRunPreset();
|
||||
// the preset body used to carry flat kic/kid/... fields that no longer
|
||||
// exist on a stored preset - the server resolved an empty keyset and
|
||||
// refused every SCP80 run
|
||||
assert.deepStrictEqual(found, { preset_id: 'p1' });
|
||||
assert.strictEqual(_testLastPresetIdx, 0);
|
||||
});
|
||||
|
||||
test('the run refuses without a matched or complete preset', () => {
|
||||
globalThis.cards = [];
|
||||
globalThis.cardsMatchedPreset = () => null;
|
||||
assert.match(testRunPreset(true).error, /No card preset matches/);
|
||||
const preset = { id: 'p2' };
|
||||
globalThis.cards = [preset];
|
||||
globalThis.cardsMatchedPreset = () => preset;
|
||||
globalThis.cardsScp80Complete = () => false;
|
||||
assert.match(testRunPreset(true).error, /incomplete SCP80/);
|
||||
// an ADM-only run needs the preset but not the SCP80 completeness
|
||||
assert.strictEqual(testRunPreset(false).preset_id, 'p2');
|
||||
globalThis.cardsScp80Complete = () => true;
|
||||
assert.strictEqual(testRunPreset(true).preset_id, 'p2');
|
||||
});
|
||||
|
||||
test('the client-side problem check bounds the keyset number', () => {
|
||||
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { apdu: '80E2', kvn: 2 } }] }), '');
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { apdu: '80E2', kvn: 16 } }] }), /keyset number/);
|
||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'scp80',
|
||||
params: { apdu: '80E2', kvn: 0 } }] }), /keyset number/);
|
||||
});
|
||||
|
||||
test('the PoR and file-list checks render and collect', () => {
|
||||
const els = { 'test-checks': {} };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
_testEditChecks = [{ kind: 'por', status: 'por_ok',
|
||||
sw: { mode: 'mask', value: '6A8?' }, on_fail: 'error' }];
|
||||
testRenderChecks();
|
||||
const rendered = els['test-checks'].innerHTML;
|
||||
assert.ok(rendered.includes('value="por" selected'), rendered);
|
||||
assert.match(rendered, /id="test-check-porstatus-0"/);
|
||||
assert.match(rendered, /id="test-check-por-sw-0"/);
|
||||
|
||||
const values = {
|
||||
'test-check-kind-0': 'por',
|
||||
'test-check-porstatus-0': 'por_ok',
|
||||
'test-check-por-sw-0': '6A8?',
|
||||
'test-check-por-data-0': 'aabb',
|
||||
'test-check-fail-0': 'warning',
|
||||
};
|
||||
globalThis.document = { getElementById: id => (id in values ? { value: values[id] } : null) };
|
||||
testChecksCollect();
|
||||
assert.deepStrictEqual(_testEditChecks[0], {
|
||||
kind: 'por', on_fail: 'warning', status: 'por_ok',
|
||||
sw: { mode: 'mask', value: '6A8?' },
|
||||
data: { mode: 'exact', value: 'AABB' },
|
||||
});
|
||||
|
||||
_testEditChecks = [{ kind: 'files', files: ['3F007F106F3A'], on_fail: 'error' }];
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
testRenderChecks();
|
||||
assert.match(els['test-checks'].innerHTML, /id="test-check-files-0"/);
|
||||
const values2 = { 'test-check-kind-0': 'files',
|
||||
'test-check-files-0': '3F007F106F3A, 3f002fe2', 'test-check-fail-0': 'error' };
|
||||
globalThis.document = { getElementById: id => (id in values2 ? { value: values2[id] } : null) };
|
||||
testChecksCollect();
|
||||
assert.deepStrictEqual(_testEditChecks[0],
|
||||
{ kind: 'files', on_fail: 'error', files: ['3F007F106F3A', '3F002FE2'] });
|
||||
});
|
||||
|
||||
test('the check summary names the PoR and file-list checks', () => {
|
||||
assert.strictEqual(testCheckSummary({ kind: 'por', status: 'por_ok',
|
||||
sw: { mode: 'mask', value: '6A8?' }, data: { mode: 'exact', value: 'AABB' } }),
|
||||
'por status por_ok SW ~6A8? data AABB');
|
||||
assert.strictEqual(testCheckSummary({ kind: 'files', files: ['3F002FE2'] }),
|
||||
'files 3F002FE2');
|
||||
});
|
||||
|
||||
test('a new script is created in the server store and gets its id', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
calls.push([path, body]);
|
||||
return {ok: true, script: {id: 'a'.repeat(32)}};
|
||||
};
|
||||
globalThis.ioStatus = () => {};
|
||||
const s = {name: 'new', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]};
|
||||
await testScriptsWriteback(s);
|
||||
assert.strictEqual(calls[0][0], '/api/test/scripts');
|
||||
assert.strictEqual(calls[0][1].name, 'new');
|
||||
assert.strictEqual(s.id, 'a'.repeat(32)); // the store id is adopted
|
||||
assert.strictEqual(calls[1][0], '/api/test/suites'); // the member refetch
|
||||
// every later write updates by id
|
||||
await testScriptsWriteback(s);
|
||||
assert.strictEqual(calls[2][0], '/api/test/scripts/update');
|
||||
assert.strictEqual(calls[2][1].id, 'a'.repeat(32));
|
||||
});
|
||||
|
||||
test('an incomplete draft is not written to the store', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => { calls.push(path); return {}; };
|
||||
globalThis.ioStatus = () => {};
|
||||
await testScriptsWriteback({name: 'draft', steps: []});
|
||||
await testScriptsWriteback({name: 'draft', steps: [{type: 'action'}]});
|
||||
assert.deepStrictEqual(calls, []);
|
||||
});
|
||||
|
||||
test('an immediate save writes the current script through', async () => {
|
||||
const calls = [];
|
||||
globalThis.pysimFetch = async (path) => { calls.push(path); return {ok: true, script: {id: 'c'.repeat(32)}}; };
|
||||
globalThis.ioStatus = () => {};
|
||||
_testScripts = [{name: 'one', steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]}];
|
||||
_testScriptId = null;
|
||||
globalThis.testCurrentScript = () => _testScripts[0];
|
||||
testScriptsSave(true);
|
||||
await new Promise(r => setTimeout(r, 0)); // the writeback is async
|
||||
assert.deepStrictEqual(calls, ['/api/test/scripts', '/api/test/suites']);
|
||||
assert.strictEqual(_testScripts[0].id, 'c'.repeat(32));
|
||||
});
|
||||
|
||||
test('a store failure is reported, not swallowed', async () => {
|
||||
let msg = '';
|
||||
globalThis.pysimFetch = async () => ({error: 'read-only file system'});
|
||||
globalThis.ioStatus = (id, text) => { msg = text; };
|
||||
await testScriptsWriteback({name: 'x', steps: [{type: 'action', kind: 'status', params: {}}]});
|
||||
assert.match(msg, /read-only file system/);
|
||||
});
|
||||
|
||||
test('the run sends the stored script id with the edited script', async () => {
|
||||
let sent = null;
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
sent = {path, body};
|
||||
return {running: true, name: 'saved', index: 0, total: 1, steps: [], status: null};
|
||||
};
|
||||
globalThis.testCurrentScript = () => ({id: 'b'.repeat(32), name: 'saved',
|
||||
steps: [{type: 'action', kind: 'status', params: {attempts: 1}}]});
|
||||
globalThis.testRunPreset = () => { throw new Error('no preset for a status-only script'); };
|
||||
globalThis.testRunMessage = () => {};
|
||||
globalThis.testRenderRun = () => {};
|
||||
globalThis.testStartRunTimer = () => {};
|
||||
globalThis.pysimApplyAvailability = () => {};
|
||||
await testRunStart();
|
||||
assert.strictEqual(sent.path, '/api/test/run');
|
||||
assert.strictEqual(sent.body.script_id, 'b'.repeat(32));
|
||||
assert.strictEqual(sent.body.script.name, 'saved');
|
||||
assert.strictEqual(sent.body.preset_id, undefined);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// Test suite view/report tests (v3.22.0): the report renderers, the Markdown
|
||||
// export and the markup wiring of the four views under the Test script pill.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name, asyncFn) {
|
||||
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['testStatusBadge', 'testRunStepHtml', 'testReportMemberHtml',
|
||||
'testReportMarkdown', 'testSuiteProgress', 'testRoleOptions', 'testFailOptions',
|
||||
'testSuiteSave']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
code += 'var _testSuites = []; var _testScripts = []; var _testSuiteIdx = -1;'
|
||||
+ ' var _testView = "suite"; var _testSuiteSaveTimer = null;'
|
||||
+ ' var _testSuiteTarget = null; var _testScriptId = null; var _testScriptDraft = null;\n';
|
||||
eval(code);
|
||||
globalThis.t = s => s;
|
||||
globalThis.esc = s => String(s);
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
name: 'regression', status: 'error', preset: 'SE test1',
|
||||
started: 1, finished: 2,
|
||||
suite: {
|
||||
id: 'a'.repeat(32), name: 'regression', require_adm: true,
|
||||
start_reset: true, reset: {ok: true, iccid: '8901000000000000001', ms: 1234},
|
||||
session_start: 1, session_end: 1, session_changed: false,
|
||||
counters_before: {'2': '0000000010'}, counters_after: {'2': '0000000012'},
|
||||
members: [
|
||||
{script_id: 'b'.repeat(32), name: 'setup', role: 'setup',
|
||||
on_fail: 'stop', status: 'ok',
|
||||
steps: [{index: 0, type: 'action', label: 'STATUS', status: 'ok',
|
||||
checks: [{label: 'SW', ok: true, expected: '9000', actual: '9000'}]}],
|
||||
log: ['TEST-RUN [1/3 setup setup] step 1: STATUS 1/1 -> 9000'], log_truncated: false},
|
||||
{script_id: 'c'.repeat(32), name: 'alfa-dsa', role: 'member',
|
||||
on_fail: 'stop', status: 'error',
|
||||
note: 'step 2 failed',
|
||||
steps: [{index: 0, type: 'action', label: 'SCP80', status: 'ok', checks: []},
|
||||
{index: 1, type: 'expect', label: 'REFRESH', status: 'error',
|
||||
checks: [{label: 'Command', ok: false, expected: 'REFRESH',
|
||||
actual: '(none)', level: 'error'}]}],
|
||||
log: ['TEST-RUN [2/3 member alfa-dsa] step 1: SCP80 -> SW=9103'], log_truncated: true},
|
||||
{script_id: 'd'.repeat(32), name: 'teardown', role: 'teardown',
|
||||
on_fail: 'stop', status: 'skipped', steps: [], log: [], log_truncated: false},
|
||||
],
|
||||
summary: {ok: 1, warning: 0, error: 1, skipped: 1, stopped: false,
|
||||
session_changed: false, wall_ms: 12345,
|
||||
counters_before: {'2': '0000000010'}, counters_after: {'2': '0000000012'},
|
||||
setup: 'ok', teardown: 'skipped'},
|
||||
},
|
||||
adm: {required: true, verified: true},
|
||||
};
|
||||
}
|
||||
|
||||
test('testReportMarkdown renders the members, steps, notes and logs', () => {
|
||||
const md = testReportMarkdown(snapshot());
|
||||
assert.match(md, /# Test suite report: regression/);
|
||||
assert.match(md, /- status: error/);
|
||||
assert.match(md, /- Preset: SE test1/);
|
||||
assert.match(md, /- started with a card reset \(ICCID 8901000000000000001\) \u00b7 1\.2 s/);
|
||||
assert.match(md, /passed: 1, warnings: 0, failed: 1, skipped: 1/);
|
||||
assert.match(md, /wall time: 12\.3 s/);
|
||||
assert.match(md, /SCP80: 2 0000000010 -> 0000000012/);
|
||||
assert.match(md, /## 2\. alfa-dsa \(member, on_fail: stop\) - error/);
|
||||
assert.match(md, /> step 2 failed/);
|
||||
assert.match(md, /### Step 2: REFRESH - error/);
|
||||
assert.match(md, /- ✗ Command: expected REFRESH, actual \(none\)/);
|
||||
assert.match(md, /TEST-RUN \[2\/3 member alfa-dsa\] step 1: SCP80 -> SW=9103/);
|
||||
assert.match(md, /## 3\. teardown \(teardown, on_fail: stop\) - skipped/);
|
||||
});
|
||||
|
||||
test('testReportMarkdown tolerates an empty snapshot', () => {
|
||||
assert.match(testReportMarkdown(null), /# Test suite report: /);
|
||||
});
|
||||
|
||||
test('testReportMemberHtml carries the status, note and log details', () => {
|
||||
const member = snapshot().suite.members[1];
|
||||
const htmlOut = testReportMemberHtml(member, 1);
|
||||
assert.match(htmlOut, /alfa-dsa/);
|
||||
assert.match(htmlOut, /step 2 failed/);
|
||||
assert.match(htmlOut, /Run log/);
|
||||
assert.match(htmlOut, /truncated/);
|
||||
assert.match(htmlOut, /Command: /);
|
||||
const teardown = testReportMemberHtml(snapshot().suite.members[2], 2);
|
||||
assert.match(teardown, /teardown/);
|
||||
assert.match(teardown, /skipped/);
|
||||
});
|
||||
|
||||
test('testSuiteProgress counts the finished members only', () => {
|
||||
assert.strictEqual(testSuiteProgress(snapshot()), '3/3');
|
||||
const snap = snapshot();
|
||||
snap.suite.members[1].status = 'running';
|
||||
snap.suite.members[2].status = 'pending';
|
||||
assert.strictEqual(testSuiteProgress(snap), '1/3');
|
||||
});
|
||||
|
||||
test('the role and on_fail selects mark the stored value', () => {
|
||||
assert.match(testRoleOptions('setup'), /value="setup" selected/);
|
||||
assert.match(testRoleOptions('member'), /value="member" selected/);
|
||||
assert.doesNotMatch(testRoleOptions('member'), /value="setup" selected/);
|
||||
assert.match(testFailOptions('continue'), /value="continue" selected/);
|
||||
assert.match(testFailOptions('stop'), /value="stop" selected/);
|
||||
});
|
||||
|
||||
test('the Test script pill hosts the four suite views and their wiring', () => {
|
||||
for (const id of ['test-view-suites', 'test-view-suite', 'test-view-script', 'test-view-report']) {
|
||||
assert.ok(html.includes('id="' + id + '"'), 'missing ' + id);
|
||||
}
|
||||
for (const fn of ['testSuiteNew', 'testSuiteOpen', 'testSuiteDelete', 'testSuiteExport',
|
||||
'testSuiteRun', 'testSuiteAddScript', 'testSuiteDeleteScript', 'testSuiteMoveScript',
|
||||
'testSuiteTargetGo', 'testOpenScript', 'testScriptBack', 'testReportExportJson',
|
||||
'testReportExportMarkdown', 'testImportFile', 'testViewShow']) {
|
||||
assert.ok(html.includes('function ' + fn) || html.includes('async function ' + fn),
|
||||
'missing ' + fn);
|
||||
}
|
||||
assert.ok(/id="test-suite-name"[^>]*oninput="testSuiteNameChanged\(\)"/.test(html));
|
||||
assert.ok(/id="test-suite-adm"[^>]*onchange="testSuiteAdmChanged\(\)"/.test(html));
|
||||
assert.ok(/id="test-suite-reset"[^>]*onchange="testSuiteResetChanged\(\)"/.test(html));
|
||||
assert.ok(/data-l10n-title="Reset and fully re-initialize the card/.test(html));
|
||||
assert.ok(/id="test-script-adm"[^>]*onchange="testScriptAdmChanged\(\)"/.test(html));
|
||||
assert.ok(/id="test-suite-run-btn"[^>]*data-needs="card"/.test(html));
|
||||
assert.ok(/id="test-run-btn"[^>]*data-needs="card"/.test(html));
|
||||
});
|
||||
|
||||
test('the suite run opens the report view and names the preset', () => {
|
||||
const src = extractFunc(html, 'testSuiteRun', true);
|
||||
assert.match(src, /'\/api\/test\/run'/);
|
||||
assert.match(src, /suite_id/);
|
||||
assert.match(src, /_testView = 'report'/);
|
||||
assert.match(src, /testRunPreset\(usesScp80\)/);
|
||||
assert.match(src, /needsAdm/);
|
||||
});
|
||||
|
||||
test('a single script run sends its require_adm flag and keeps its id', () => {
|
||||
const src = extractFunc(html, 'testRunStart', true);
|
||||
assert.match(src, /require_adm: !!cur\.require_adm/);
|
||||
assert.match(src, /body\.script_id = cur\.id/);
|
||||
assert.match(src, /testRenderRun\(resp\)/);
|
||||
});
|
||||
|
||||
test('the suite save adopts the stored suite (the server normalises the order)', async () => {
|
||||
const calls = [];
|
||||
const suite = {id: 'a'.repeat(32), name: 'edited', require_adm: false,
|
||||
start_reset: true, scripts: []};
|
||||
_testSuites = [suite];
|
||||
_testSuiteIdx = 0;
|
||||
globalThis.testCurrentSuite = () => suite;
|
||||
globalThis.testSuites = () => _testSuites;
|
||||
globalThis.testRenderSuiteScripts = () => {};
|
||||
globalThis.ioStatus = () => {};
|
||||
globalThis.pysimFetch = async (path, body) => {
|
||||
calls.push([path, body]);
|
||||
return {ok: true, suite: Object.assign({}, suite, {name: 'stored'})};
|
||||
};
|
||||
testSuiteSave(true);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
assert.strictEqual(calls[0][0], '/api/test/suites/update');
|
||||
assert.strictEqual(calls[0][1].suite.scripts.length, 0);
|
||||
assert.strictEqual(calls[0][1].suite.start_reset, true);
|
||||
assert.strictEqual(_testSuites[0].name, 'stored');
|
||||
});
|
||||
|
||||
test('role rows cannot be moved and the i18n refresh redraws the test views', () => {
|
||||
const rows = extractFunc(html, 'testRenderSuiteScripts');
|
||||
assert.match(rows, /const movable = e\.role === 'member'/);
|
||||
assert.match(rows, /!movable \|\| i === 0/);
|
||||
assert.match(rows, /!movable \|\| i === entries\.length - 1/);
|
||||
const del = extractFunc(html, 'testSuiteDelete', true);
|
||||
assert.match(del, /_testSuiteSaveTimer/);
|
||||
const i18n = extractFunc(html, 'refreshDynamicI18n');
|
||||
assert.match(i18n, /isViewVisible\('phone-sub-test'\)\) testRender\(\)/);
|
||||
});
|
||||
|
||||
test('deleting a suite re-resolves the selection by id, not by index', async () => {
|
||||
// The delete shifts every index after it and the list may be reordered
|
||||
// while the confirmation is open (the modal does not block the page).
|
||||
const del = extractFunc(html, 'testSuiteDelete', true);
|
||||
const make = state => new Function('state', `
|
||||
var _testSuiteIdx = state.selIdx;
|
||||
var _testSuiteSaveTimer = null;
|
||||
var testSuites = () => state.suites;
|
||||
var testCurrentSuite = () => state.suites[_testSuiteIdx];
|
||||
var pysimConfirm = async () => {
|
||||
if (state.moveTo !== undefined && state.moveTo !== null) _testSuiteIdx = state.moveTo;
|
||||
return true;
|
||||
};
|
||||
var pysimFetch = async (path, body) => { state.deleted = body.id; return {}; };
|
||||
var testStoresFetch = async () => {
|
||||
state.suites = state.suites.filter(x => x.id !== state.deleted);
|
||||
};
|
||||
var testViewShow = v => { state.view = v; };
|
||||
var ioStatus = (id, msg) => { state.status = msg; };
|
||||
var t = x => x;
|
||||
${del}
|
||||
return { run: testSuiteDelete, idx: () => _testSuiteIdx };
|
||||
`)(state);
|
||||
// deleting the SELECTED suite clears the selection
|
||||
let app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 2 });
|
||||
await app.run(2);
|
||||
assert.strictEqual(app.idx(), -1);
|
||||
// deleting a suite BEFORE the selection keeps it selected (the index shifts)
|
||||
app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 2 });
|
||||
await app.run(0);
|
||||
assert.strictEqual(app.idx(), 1);
|
||||
// a selection made while the dialog was open wins
|
||||
app = make({ suites: [{id: 'SA'}, {id: 'SB'}, {id: 'SC'}], selIdx: 0, moveTo: 1 });
|
||||
await app.run(2);
|
||||
assert.strictEqual(app.idx(), 1);
|
||||
// nothing selected stays nothing selected
|
||||
app = make({ suites: [{id: 'SA'}], selIdx: -1 });
|
||||
await app.run(0);
|
||||
assert.strictEqual(app.idx(), -1);
|
||||
});
|
||||
|
||||
test('the report keeps an expanded run log open across the live polls', () => {
|
||||
// A live suite run re-renders the member blocks on every poll; replacing
|
||||
// innerHTML creates fresh <details>, so the open state is carried over
|
||||
// by the stable member index.
|
||||
const member = extractFunc(html, 'testReportMemberHtml');
|
||||
assert.match(member, /data-member="/);
|
||||
const report = extractFunc(html, 'testRenderReport');
|
||||
assert.match(report, /details\[data-member\]/);
|
||||
assert.match(report, /openLogs\[d\.dataset\.member\]/);
|
||||
assert.match(report, /d\.open = true/);
|
||||
});
|
||||
|
||||
test('the report header classes are generated in the prebuilt stylesheet', () => {
|
||||
// The stale-style trap (v3.22.0 review): style.css is a prebuilt Tailwind
|
||||
// output - a class used only in the PWA source has no rule until
|
||||
// `npm run build` runs in frontend/. The report's member header is the
|
||||
// regression case (a near-white bg + dark-theme text made titles vanish).
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
|
||||
for (const sel of ['.bg-gray-50', '.dark\\:bg-slate-800\\/50',
|
||||
'.dark\\:text-slate-200', '.bg-purple-100', '.dark\\:bg-purple-900\\/40',
|
||||
'.min-w-\\[12rem\\]']) {
|
||||
assert.ok(css.includes(sel), 'missing rule for ' + sel);
|
||||
}
|
||||
assert.match(extractFunc(html, 'testReportMemberHtml'),
|
||||
/bg-gray-50 dark:bg-slate-800\/50/);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"_note": "TS 102 223 v18.3.0 Tables 6.1/6.2/6.3 \"Proactive commands versus possible terminal response\", parsed from the pinned PDF (docs/uicc/, workspace copy) - which result values the spec lists for each proactive command. The PWA's TR_RESULT_BY_COMMAND mirrors this file (frontend/tests/tr_results.test.js asserts it); the list is used for GROUPING only (the command-relevant values first, the rest after - nothing is hidden). Regenerate with a pdftotext -layout parse of the table pages if the spec pin changes.",
|
||||
"commands": {
|
||||
"01": ["00", "01", "02", "03", "04", "08", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"02": ["00", "01", "02", "20", "30", "31", "32", "33", "36"],
|
||||
"03": ["00", "01", "02", "20", "30", "31", "32", "33", "36"],
|
||||
"04": ["00", "01", "02", "20", "30", "31", "32", "33", "36"],
|
||||
"05": ["00", "01", "02", "20", "30", "31", "32", "33", "36"],
|
||||
"10": ["00", "01", "02", "04", "05", "10", "14", "20", "21", "22", "23", "25", "30", "31", "32", "33", "34", "36", "39", "3C"],
|
||||
"14": ["00", "01", "02", "04", "10", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"15": ["00", "01", "02", "20", "21", "26", "30", "31", "32", "33", "3C"],
|
||||
"20": ["00", "01", "02", "04", "09", "10", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"21": ["00", "01", "02", "04", "10", "11", "12", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"22": ["00", "01", "02", "04", "10", "11", "12", "13", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"23": ["00", "01", "02", "04", "10", "11", "12", "13", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"24": ["00", "01", "02", "04", "10", "11", "12", "13", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"25": ["00", "01", "02", "04", "20", "30", "31", "32", "33", "36"],
|
||||
"26": ["00", "01", "02", "06", "20", "30", "31", "32", "33", "36"],
|
||||
"27": ["00", "01", "02", "20", "24", "30", "31", "32", "33", "36"],
|
||||
"28": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"30": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "38"],
|
||||
"31": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "38"],
|
||||
"32": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "38"],
|
||||
"33": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "38"],
|
||||
"34": ["00", "01", "02", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"35": ["00", "01", "02", "20", "30", "31", "32", "33", "36"],
|
||||
"40": ["00", "01", "02", "04", "07", "10", "20", "21", "22", "25", "28", "30", "31", "32", "33", "36", "3A", "3B", "3C"],
|
||||
"41": ["00", "01", "02", "04", "10", "20", "30", "31", "32", "33", "36", "3A", "3C"],
|
||||
"42": ["00", "01", "02", "04", "10", "20", "30", "31", "32", "33", "36", "3A", "3C"],
|
||||
"43": ["00", "01", "02", "04", "10", "20", "21", "22", "30", "31", "32", "33", "36", "3A", "3C"],
|
||||
"44": ["00", "01", "02", "04", "10", "20", "30", "31", "32", "33", "36"],
|
||||
"45": ["00", "01", "04", "10", "20", "30", "31", "32", "33", "36", "3A", "3B", "3C"],
|
||||
"46": ["00", "01", "04", "10", "20", "30", "31", "32", "33", "36", "3A", "3B", "3C"],
|
||||
"47": ["00", "01", "02", "07", "20", "30", "31", "32", "33", "36", "3A"],
|
||||
"50": ["00", "01", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"51": ["00", "01", "20", "30", "31", "32", "33", "36", "3C"],
|
||||
"60": ["00", "01", "02", "27", "30", "31", "32", "33", "36", "3D"],
|
||||
"61": ["00", "01", "02", "27", "30", "31", "32", "33", "36", "3D"],
|
||||
"62": ["00", "01", "02", "10", "27", "30", "31", "32", "33", "36", "3D"],
|
||||
"70": ["00", "01", "20", "30", "31", "32", "33", "36"],
|
||||
"71": ["00", "01", "20", "30", "31", "32", "33"],
|
||||
"72": ["00", "01", "20", "30", "31", "32", "33", "36"],
|
||||
"73": ["00", "01", "20", "30", "31", "32", "33", "36"],
|
||||
"79": ["00", "01", "20", "30", "31", "32", "36"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"comment": "TERMINAL RESPONSE general results (TS 102 223 v18.3.0 8.12.0) and the terminal-problem additional information (8.12.2). Mirrored by pysim_simple_server/testscript.py (TR_RESULTS/TR_RESULT_ADD_INFO/RESULT_NAMES) and frontend/index.html (the same names); both suites drift-guard against this fixture.",
|
||||
"results": [
|
||||
{"v": "00", "name": "Command performed successfully", "alias": "ok"},
|
||||
{"v": "01", "name": "Command performed with partial comprehension", "alias": "partial"},
|
||||
{"v": "02", "name": "Command performed, with missing information", "alias": "missing"},
|
||||
{"v": "03", "name": "REFRESH performed with additional EFs read"},
|
||||
{"v": "04", "name": "Command performed successfully, but requested icon could not be displayed"},
|
||||
{"v": "05", "name": "Command performed, but modified by call control by NAA"},
|
||||
{"v": "06", "name": "Command performed successfully, limited service"},
|
||||
{"v": "07", "name": "Command performed with modification", "alias": "modified"},
|
||||
{"v": "08", "name": "REFRESH performed but indicated NAA was not active"},
|
||||
{"v": "09", "name": "Command performed successfully, tone not played"},
|
||||
{"v": "10", "name": "Proactive UICC session terminated by the user", "alias": "cancel"},
|
||||
{"v": "11", "name": "Backward move in the proactive UICC session requested by the user", "alias": "back"},
|
||||
{"v": "12", "name": "No response from user", "alias": "timeout"},
|
||||
{"v": "13", "name": "Help information required by the user"},
|
||||
{"v": "14", "name": "Reserved for GSM/3G"},
|
||||
{"v": "15", "name": "Reserved for 3GPP (for future usage)"},
|
||||
{"v": "16", "name": "Reserved for 3GPP (for future usage)"},
|
||||
{"v": "20", "name": "Terminal currently unable to process command"},
|
||||
{"v": "21", "name": "Network currently unable to process command"},
|
||||
{"v": "22", "name": "User did not accept the proactive command", "alias": "refused"},
|
||||
{"v": "23", "name": "User cleared down call before connection or network release"},
|
||||
{"v": "24", "name": "Action in contradiction with the current timer state"},
|
||||
{"v": "25", "name": "Interaction with call control by NAA, temporary problem"},
|
||||
{"v": "26", "name": "Launch browser generic error"},
|
||||
{"v": "27", "name": "MMS temporary problem"},
|
||||
{"v": "28", "name": "Reserved for 3GPP (BIP temporary error, T timer in the additional information)"},
|
||||
{"v": "29", "name": "Reserved for 3GPP (for future usage)"},
|
||||
{"v": "30", "name": "Command beyond terminal's capabilities"},
|
||||
{"v": "31", "name": "Command type not understood by terminal"},
|
||||
{"v": "32", "name": "Command data not understood by terminal", "alias": "not_understood"},
|
||||
{"v": "33", "name": "Command number not known by terminal"},
|
||||
{"v": "34", "name": "Reserved for GSM/3G"},
|
||||
{"v": "35", "name": "Reserved for GSM/3G"},
|
||||
{"v": "36", "name": "Error, required values are missing"},
|
||||
{"v": "37", "name": "Reserved for GSM/3G"},
|
||||
{"v": "38", "name": "MultipleCard commands error"},
|
||||
{"v": "39", "name": "Interaction with call control by NAA, permanent problem"},
|
||||
{"v": "3A", "name": "Bearer Independent Protocol permanent error"},
|
||||
{"v": "3B", "name": "Access Technology unable to process command"},
|
||||
{"v": "3C", "name": "Frames error"},
|
||||
{"v": "3D", "name": "MMS Error"},
|
||||
{"v": "3E", "name": "Reserved for 3GPP (for future usage)"},
|
||||
{"v": "3F", "name": "Reserved for 3GPP (for future usage)"}
|
||||
],
|
||||
"aliases": {
|
||||
"ok": "00",
|
||||
"partial": "01",
|
||||
"missing": "02",
|
||||
"refused": "22",
|
||||
"not_understood": "32",
|
||||
"modified": "07",
|
||||
"cancel": "10",
|
||||
"back": "11",
|
||||
"timeout": "12",
|
||||
"no_response": "12"
|
||||
},
|
||||
"additional_info": [
|
||||
{"v": "00", "name": "No specific cause can be given"},
|
||||
{"v": "01", "name": "Screen is busy"},
|
||||
{"v": "02", "name": "Terminal currently busy on call"},
|
||||
{"v": "03", "name": "Reserved for GSM/3G"},
|
||||
{"v": "04", "name": "No service"},
|
||||
{"v": "05", "name": "Access control class bar"},
|
||||
{"v": "06", "name": "Radio resource not granted"},
|
||||
{"v": "07", "name": "Not in speech call"},
|
||||
{"v": "08", "name": "Reserved for GSM/3G"},
|
||||
{"v": "09", "name": "Terminal currently busy on SEND DTMF command"},
|
||||
{"v": "0A", "name": "No NAA active"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'tr_results.json'), 'utf8'));
|
||||
|
||||
function extractBlock(startMarker, endMarker) {
|
||||
const start = html.indexOf(startMarker);
|
||||
const end = html.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Rewrite top-level const -> var so the tables leak out of sloppy-mode eval.
|
||||
eval(extractBlock('const TR_RESULTS = [', 'const TR_RESULT_ADD_INFO = [').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_ADD_INFO = [', '// TS 102 223 v18.3.0 Tables 6.1/6.2').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_BY_COMMAND = {', 'const TR_RESULT_ALIASES = {').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const TR_RESULT_ALIASES = {', 'function trCommandCode').replace(/^const /gm, 'var '));
|
||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
||||
eval(extractFunc(html, 'esc'));
|
||||
eval(extractFunc(html, 'trCommandCode'));
|
||||
eval(extractFunc(html, 'trResultOptions'));
|
||||
eval(extractFunc(html, 'trResultFormValue'));
|
||||
eval(extractFunc(html, 'trResultValid'));
|
||||
eval(extractFunc(html, 'trResultCollect'));
|
||||
eval(extractFunc(html, 'testTrResultChanged'));
|
||||
eval(extractFunc(html, 'testFormInput'));
|
||||
eval(extractFunc(html, 'testFormSelect'));
|
||||
eval(extractFunc(html, 'trResultRowHtml'));
|
||||
|
||||
globalThis.t = s => s;
|
||||
|
||||
const fixtureResults = fixture.results.map(r => {
|
||||
const o = { v: r.v, name: r.name };
|
||||
if (r.alias) o.alias = r.alias;
|
||||
return o;
|
||||
});
|
||||
|
||||
test('the PWA result tables match the shared fixture', () => {
|
||||
assert.deepStrictEqual(TR_RESULTS, fixtureResults);
|
||||
assert.deepStrictEqual(TR_RESULT_ADD_INFO, fixture.additional_info);
|
||||
assert.deepStrictEqual(TR_RESULT_ALIASES, fixture.aliases);
|
||||
});
|
||||
|
||||
test('the corrected aliases carry the TS 102 223 8.12.0 values', () => {
|
||||
assert.strictEqual(TR_RESULT_ALIASES.refused, '22');
|
||||
assert.strictEqual(TR_RESULT_ALIASES.not_understood, '32');
|
||||
assert.strictEqual(TR_RESULT_ALIASES.modified, '07');
|
||||
assert.strictEqual(TR_RESULT_ALIASES.no_response, '12');
|
||||
assert.strictEqual(TR_RESULT_ALIASES.ok, '00');
|
||||
});
|
||||
|
||||
test('trResultOptions labels every entry with its hex value', () => {
|
||||
const opts = trResultOptions();
|
||||
// the full list plus the manual entry
|
||||
assert.strictEqual(opts.length, TR_RESULTS.length + 1);
|
||||
assert.deepStrictEqual(opts[0], { v: 'ok', l: '00 - ok' });
|
||||
const byValue = {};
|
||||
opts.forEach(o => { byValue[o.v] = o.l; });
|
||||
assert.strictEqual(byValue.modified, '07 - modified');
|
||||
assert.strictEqual(byValue['09'], '09 - Command performed successfully, tone not played');
|
||||
assert.strictEqual(byValue['20'], '20 - Terminal currently unable to process command');
|
||||
assert.strictEqual(byValue['3F'], '3F - Reserved for 3GPP (for future usage)');
|
||||
assert.deepStrictEqual(opts[opts.length - 1], { v: 'manual', l: 'manual input' });
|
||||
// the compat-only alias is not offered (12's canonical alias is timeout)
|
||||
assert.ok(!('no_response' in byValue), 'no_response must not be an option');
|
||||
});
|
||||
|
||||
test('trResultFormValue maps the stored alias/hex/manual values', () => {
|
||||
assert.deepStrictEqual(trResultFormValue('ok'), { value: 'ok', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue('OK'), { value: 'ok', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue('07'), { value: 'modified', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue('0x09'), { value: '09', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue('1a'), { value: 'manual', manual: '1A' });
|
||||
assert.deepStrictEqual(trResultFormValue(''), { value: '', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue(undefined), { value: '', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue('bogus'), { value: 'ok', manual: '' });
|
||||
// a numeric stored value is the value, not hex (the engine reads ints)
|
||||
assert.deepStrictEqual(trResultFormValue(7), { value: 'modified', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue(16), { value: 'cancel', manual: '' });
|
||||
assert.deepStrictEqual(trResultFormValue(10), { value: 'manual', manual: '0A' });
|
||||
assert.deepStrictEqual(trResultFormValue(0), { value: 'ok', manual: '' });
|
||||
});
|
||||
|
||||
test('trResultValid accepts the aliases and hex bytes only', () => {
|
||||
['ok', 'refused', '07', '0x0a', 'FF'].forEach(v => assert.ok(trResultValid(v), v));
|
||||
['manual', '', 'f', 'zzz'].forEach(v => assert.ok(!trResultValid(v), v));
|
||||
});
|
||||
|
||||
test('the step editor offers the full list for all three result fields', () => {
|
||||
const form = extractFunc(html, 'testStepRender');
|
||||
assert.ok(form.includes("trResultRowHtml('test-f-result'"), 'the expectation result');
|
||||
assert.ok(form.includes("trResultRowHtml('test-f-presult'"), 'the drain result');
|
||||
assert.ok(form.includes("trResultRowHtml('test-f-pfirst'"), 'the drain first');
|
||||
assert.ok(form.includes("{empty: 'same as above'}"), 'the first keeps its empty option');
|
||||
// the suggestion list is rendered by the first result row of each form
|
||||
// (the drain's respond and the expectation), never twice in one form
|
||||
assert.strictEqual((form.match(/\{list: true/g) || []).length, 2, form);
|
||||
});
|
||||
|
||||
test('trResultRowHtml renders the manual entry and the additional info', () => {
|
||||
const out = trResultRowHtml('test-f-result', '07', '04', { list: true });
|
||||
assert.ok(out.includes('07 - modified'), out);
|
||||
assert.ok(out.includes('value="04"'), out);
|
||||
assert.ok(out.includes('id="tr-addinfo-list"'), out);
|
||||
assert.ok(out.includes('No specific cause can be given'), out);
|
||||
assert.ok(out.includes('id="test-f-result-hexwrap" class="w-24 hidden"'), out);
|
||||
const manual = trResultRowHtml('test-f-result', '1A', '');
|
||||
assert.ok(manual.includes('id="test-f-result-hexwrap" class="w-24"'), manual);
|
||||
assert.ok(manual.includes('value="1A"'), manual);
|
||||
assert.ok(manual.includes('<option value="manual" selected>'), manual);
|
||||
// a server-normalised (integer) additional info shows as hex
|
||||
const intAdd = trResultRowHtml('test-f-result', '07', 10);
|
||||
assert.ok(intAdd.includes('value="0A"'), intAdd);
|
||||
// the suggestion list is rendered once per form: only the row with the
|
||||
// flag carries it (a form may have several result rows)
|
||||
assert.ok(!manual.includes('id="tr-addinfo-list"'), manual);
|
||||
assert.ok(!intAdd.includes('id="tr-addinfo-list"'), intAdd);
|
||||
const twoRows = trResultRowHtml('test-f-presult', 'ok', '', { list: true })
|
||||
+ trResultRowHtml('test-f-pfirst', '', '', { empty: 'same as above' });
|
||||
assert.strictEqual((twoRows.match(/id="tr-addinfo-list"/g) || []).length, 1, twoRows);
|
||||
});
|
||||
|
||||
test('the command/result grouping table matches the parsed spec fixture', () => {
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'tr_result_by_command.json'), 'utf8'));
|
||||
assert.deepStrictEqual(TR_RESULT_BY_COMMAND, fixture.commands);
|
||||
// the editor's command names resolve to their type codes (the first code
|
||||
// wins, matching testCommandOptions' de-duplication)
|
||||
assert.strictEqual(trCommandCode('REFRESH'), 0x01);
|
||||
assert.strictEqual(trCommandCode('PLAY TONE'), 0x20);
|
||||
assert.strictEqual(trCommandCode('TIMER MANAGEMENT'), 0x27);
|
||||
assert.strictEqual(trCommandCode('OPEN CHANNEL'), 0x40);
|
||||
assert.ok(Number.isNaN(trCommandCode('ANY')));
|
||||
assert.ok(Number.isNaN(trCommandCode('NONSENSE')));
|
||||
// every mapped code is a known proactive command
|
||||
for (const code of Object.keys(TR_RESULT_BY_COMMAND)) {
|
||||
assert.ok(CMD_NAMES[code], 'the mapped code 0x' + code + ' must be a known command');
|
||||
}
|
||||
// independent semantic pins (from the spec's command clauses): a column
|
||||
// shift in the parsed table would break these
|
||||
const has = (cmd, res) => TR_RESULT_BY_COMMAND[cmd].indexOf(res) >= 0;
|
||||
assert.ok(has('01', '03') && has('01', '08'), 'REFRESH: additional EFs / NAA not active');
|
||||
assert.ok(has('20', '09'), 'PLAY TONE: tone not played');
|
||||
assert.ok(!has('01', '09') && !has('20', '03'), 'the command-specific values do not leak');
|
||||
assert.ok(has('27', '24'), 'TIMER MANAGEMENT: timer state');
|
||||
assert.ok(has('15', '26'), 'LAUNCH BROWSER: generic error');
|
||||
assert.ok(has('40', '28') && has('40', '3A') && has('40', '3B'), 'OPEN CHANNEL: BIP / access technology');
|
||||
assert.ok(has('60', '27') && has('60', '3D'), 'the MMS commands: MMS errors');
|
||||
assert.ok(has('33', '38'), 'GET READER STATUS: MultipleCard');
|
||||
// the spec's tables omit SEND SS / SEND USSD / SEND SHORT MESSAGE /
|
||||
// GEOGRAPHICAL LOCATION REQUEST / End of the proactive UICC session -
|
||||
// those keep the flat list (the fallback is checked in the grouping test)
|
||||
assert.ok(trCommandCode('End of the proactive UICC session') === 0x81,
|
||||
'a mixed-case command name must still resolve');
|
||||
});
|
||||
|
||||
test('trResultOptions groups the list by the selected command', () => {
|
||||
const flat = trResultOptions();
|
||||
assert.strictEqual(flat.filter(o => o.group).length, 0, 'no groups without a command');
|
||||
assert.strictEqual(flat.length, TR_RESULTS.length + 1);
|
||||
assert.strictEqual(trResultOptions({ command: 'ANY' }).filter(o => o.group).length, 0,
|
||||
'ANY stays flat');
|
||||
// a command the spec's tables omit keeps the flat list too
|
||||
assert.strictEqual(trResultOptions({ command: 'SEND SS' }).filter(o => o.group).length, 0,
|
||||
'SEND SS (not in the tables) stays flat');
|
||||
const grouped = trResultOptions({ command: 'REFRESH' });
|
||||
assert.deepStrictEqual(grouped.filter(o => o.group).map(o => o.group),
|
||||
['relevant for REFRESH', 'all other results']);
|
||||
// the first group carries the spec's REFRESH set in the spec order
|
||||
const split = grouped.findIndex(o => o.group === 'all other results');
|
||||
const first = grouped.slice(1, split);
|
||||
assert.deepStrictEqual(first.map(o => o.v),
|
||||
TR_RESULT_BY_COMMAND['01'].map(hex => (TR_RESULTS.find(r => r.v === hex) || {}).alias || hex));
|
||||
// nothing is hidden: both groups together are the full list + the manual
|
||||
// entry, and the stored value always matches an option
|
||||
const values = grouped.filter(o => !o.group).map(o => o.v).sort();
|
||||
const all = TR_RESULTS.map(r => r.alias || r.v).concat(['manual']).sort();
|
||||
assert.deepStrictEqual(values, all);
|
||||
});
|
||||
|
||||
test('testFormSelect renders the optgroups', () => {
|
||||
const out = testFormSelect('x', 'ok', [{ group: 'relevant for REFRESH' }, { v: 'ok', l: '00 - ok' },
|
||||
{ group: 'all other results' }, { v: '1F', l: '1F - Slices status change' }]);
|
||||
assert.ok(out.includes('<optgroup label="relevant for REFRESH">'), out);
|
||||
assert.ok(out.includes('<optgroup label="all other results">'), out);
|
||||
assert.strictEqual((out.match(/<optgroup/g) || []).length, 2, out);
|
||||
assert.strictEqual((out.match(/<\/optgroup>/g) || []).length, 2, out);
|
||||
assert.ok(out.includes('<option value="ok" selected>00 - ok</option>'), out);
|
||||
// the flat form is unchanged
|
||||
assert.ok(!testFormSelect('y', 'ok', [{ v: 'ok', l: '00 - ok' }]).includes('optgroup'));
|
||||
});
|
||||
|
||||
test('the expectation result row passes the selected command', () => {
|
||||
const form = extractFunc(html, 'testStepRender');
|
||||
assert.ok(/trResultRowHtml\('test-f-result'[\s\S]*?command: step\.command \|\| 'ANY'/.test(form),
|
||||
'the expectation result must group by the selected command');
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// Command builders and the expanded-script wiring they feed.
|
||||
// Reference vectors are computed from the pinned specs:
|
||||
// TS 102 221 11.1.7/11.1.8 (SEARCH RECORD, INCREASE), 11.3 (SET/RETRIEVE DATA)
|
||||
// TS 102 222 tables 2/7/16/17 (CREATE/DELETE/RESIZE FILE, FCP templates)
|
||||
// TS 151 011 9.2.7/9.2.8 (SEEK, INCREASE for 2G SIM)
|
||||
// TS 102 226 9.2.1-9.2.4 (PUSH), 5.2.1 (Command TLVs)
|
||||
// GlobalPlatform Card Spec 11.8 (PUT KEY)
|
||||
const FNS = ['berLenStr', 'buildApdu', 'buildSelect', 'escHtml', 'esc', 'chainInit',
|
||||
'chainKind', 'chainIsEmbedded', 'chainCommands', 'chainBuildRowHex',
|
||||
'chainSimBuildRowHex', 'chainRamBuildRowHex', 'stkParamsBuild', 'chainPushData', '_hotaAsciiHex',
|
||||
'chainApduList', 'chainBuildFcp', 'pushSectionApdus', 'pushOpenChannelTlvs'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['CHAIN_CMDS_SIM', 'CHAIN_CMDS_USIM', 'CHAIN_CMDS_RAM']) {
|
||||
const m = html.match(new RegExp('const ' + c + ' = \\[[\\s\\S]*?\\n\\];'));
|
||||
if (!m) throw new Error('catalog not found: ' + c);
|
||||
code += m[0].replace('const ', 'var ') + '\n';
|
||||
}
|
||||
code += html.match(/const _chains = \{\};/)[0].replace('const ', 'var ') + '\n';
|
||||
code += html.match(/const CHAIN_LISTENERS = \{\};/)[0].replace('const ', 'var ') + '\n';
|
||||
// The real chain renderers are DOM-bound; the pure builders under test only
|
||||
// need chainRender as a no-op, and chainApduList's splitter is exercised with
|
||||
// a stub (the splitter itself is covered by apdu_parse.test.js).
|
||||
code += 'function chainRender() {}\n';
|
||||
code += 'var __split = [];\nfunction parseCompactApdus() { return __split; }\n';
|
||||
eval(code);
|
||||
|
||||
function row(cmd, fields) {
|
||||
return { cmd: cmd, fields: fields || {} };
|
||||
}
|
||||
|
||||
test('the command catalogs cover the TS 102 226 table 7.1/8.1/9.1 gaps', () => {
|
||||
const sim = CHAIN_CMDS_SIM.map(c => c.value);
|
||||
const usim = CHAIN_CMDS_USIM.map(c => c.value);
|
||||
const ram = CHAIN_CMDS_RAM.map(c => c.value);
|
||||
['search-record', 'increase'].forEach(c => assert.ok(sim.includes(c), 'SIM ' + c));
|
||||
['search-record', 'increase', 'create-file', 'delete-file', 'resize-file', 'set-data', 'retrieve-data']
|
||||
.forEach(c => assert.ok(usim.includes(c), 'USIM ' + c));
|
||||
['put-key', 'push'].forEach(c => assert.ok(ram.includes(c), 'RAM ' + c));
|
||||
});
|
||||
|
||||
test('chainKind maps the stock and embedded chains; embedded ones hide GET RESPONSE', () => {
|
||||
assert.strictEqual(chainKind('chain-sim'), 'sim');
|
||||
assert.strictEqual(chainKind('chain-usim'), 'usim');
|
||||
assert.strictEqual(chainKind('chain-ram'), 'ram');
|
||||
assert.strictEqual(chainKind('ber-sim-7'), 'sim');
|
||||
assert.strictEqual(chainKind('ber-usim-12'), 'usim');
|
||||
assert.strictEqual(chainKind('ber-ram-1'), 'ram');
|
||||
assert.ok(chainIsEmbedded('ber-sim-3'));
|
||||
assert.ok(!chainIsEmbedded('chain-sim'));
|
||||
// GET RESPONSE is not used in the expanded format (TS 102 226 5.2.1.1).
|
||||
assert.ok(chainCommands('chain-sim').some(c => c.value === 'get-response'));
|
||||
assert.ok(!chainCommands('ber-sim-3').some(c => c.value === 'get-response'));
|
||||
assert.ok(!chainCommands('ber-ram-3').some(c => c.value === 'get-response'));
|
||||
});
|
||||
|
||||
test('USIM SEARCH RECORD: simple and enhanced (TS 102 221 11.1.7)', () => {
|
||||
_chains['chain-usim'] = { rows: [] };
|
||||
// Simple forward from record 2 (P2=10), pattern ABCD, Le=00.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '10', srRec: '02', srPattern: 'ABCD', le: '00' })),
|
||||
'00A2021002ABCD00');
|
||||
// Enhanced (P2=18): 2-byte indication + search string.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '18', srRec: '00', srInd: '1800', srPattern: 'AB', le: '00' })),
|
||||
'00A20018031800AB00');
|
||||
// Backward from record 1 (P2=12).
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('search-record', { srMode: '12', srRec: '01', srPattern: 'AA', le: '00' })),
|
||||
'00A2011201AA00');
|
||||
// Missing pattern: no APDU.
|
||||
assert.strictEqual(chainSimBuildRowHex('chain-usim', 0, row('search-record', { srPattern: '' })), '');
|
||||
});
|
||||
|
||||
test('SIM SEEK: type/mode in P2, pattern in the data field (TS 151 011 9.2.7)', () => {
|
||||
_chains['chain-sim'] = { rows: [] };
|
||||
// Type 1, from next forward.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-sim', 0, row('search-record', { srMode: '02', srPattern: '112233', le: '' })),
|
||||
'A0A2000203112233');
|
||||
// Type 2, from the beginning: Le appended.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-sim', 0, row('search-record', { srMode: '10', srPattern: '11', le: '00' })),
|
||||
'A0A20010011100');
|
||||
});
|
||||
|
||||
test('INCREASE: TS 102 221 (USIM, optional SFI) and TS 51 011 (SIM)', () => {
|
||||
_chains['chain-sim'] = { rows: [] };
|
||||
_chains['chain-usim'] = { rows: [] };
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('increase', { value: '000001', le: '00' })),
|
||||
'003200000300000100');
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('increase', { sfi: '05', value: '000001', le: '00' })),
|
||||
'003285000300000100');
|
||||
// TS 51 011 9.2.8: class A0, P1/P2 = 00, P3 = 03.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-sim', 0, row('increase', { value: '000001' })),
|
||||
'A032000003000001');
|
||||
assert.strictEqual(chainSimBuildRowHex('chain-usim', 0, row('increase', { value: '' })), '');
|
||||
});
|
||||
|
||||
test('CREATE/DELETE/RESIZE FILE (TS 102 222 tables 2, 7, 16)', () => {
|
||||
_chains['chain-usim'] = { rows: [] };
|
||||
const fcp = '62138202002183026F078A01058C027F0080020010';
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('create-file', { fcp: fcp })),
|
||||
'00E0000015' + fcp);
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('create-file', { fcp: '83026F07' })),
|
||||
'');
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('delete-file', { fid: '6F07' })),
|
||||
'00E40000026F07');
|
||||
// RESIZE FILE uses CLA 80 (TS 102 222 table 1: '8X'/'CX').
|
||||
const rfcp = '620883026F0780020010';
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('resize-file', { mode: '00', fcp: rfcp })),
|
||||
'80D400000A' + rfcp);
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('resize-file', { mode: '01', fcp: rfcp })),
|
||||
'80D401000A' + rfcp);
|
||||
});
|
||||
|
||||
test('SET DATA / RETRIEVE DATA block coding (TS 102 221 table 11.35)', () => {
|
||||
_chains['chain-usim'] = { rows: [] };
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('set-data', { sdMode: '80', sfi: '00', value: '80017F' })),
|
||||
'00DB00800380017F');
|
||||
// Next block with SFI 5.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('set-data', { sdMode: '00', sfi: '05', value: '80017F' })),
|
||||
'00DB00050380017F');
|
||||
// First block, tag 5C, Le 00.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('retrieve-data', { sdMode: '80', sfi: '00', tag: '5C', le: '00' })),
|
||||
'00CB0080015C00');
|
||||
// No tag: case 2.
|
||||
assert.strictEqual(
|
||||
chainSimBuildRowHex('chain-usim', 0, row('retrieve-data', { sdMode: '80', sfi: '00', tag: '', le: '00' })),
|
||||
'00CB008000');
|
||||
});
|
||||
|
||||
test('PUT KEY (GlobalPlatform Card Spec 11.8)', () => {
|
||||
_chains['chain-ram'] = { rows: [] };
|
||||
assert.strictEqual(
|
||||
chainRamBuildRowHex(0, row('put-key', { keyVer: '01', keyId: '02', data: '1122334455667788' })),
|
||||
'80D00102081122334455667788');
|
||||
assert.strictEqual(chainRamBuildRowHex(0, row('put-key', { data: '' })), '');
|
||||
});
|
||||
|
||||
test('PUSH: BIP channel opening and identification packet (TS 102 226 9.2.1/9.2.4)', () => {
|
||||
_chains['chain-ram'] = { rows: [] };
|
||||
// No TLVs: the application uses its defaults, so the APDU is case 1.
|
||||
assert.strictEqual(chainRamBuildRowHex(0, row('push', { pushType: '01', pushData: '' })), '80EC0101');
|
||||
assert.strictEqual(
|
||||
chainRamBuildRowHex(0, row('push', { pushType: '01', pushData: '350103' })),
|
||||
'80EC010103350103');
|
||||
assert.strictEqual(
|
||||
chainRamBuildRowHex(0, row('push', { pushType: '04', pushIdent: '0102' })),
|
||||
'80EC01040436020102');
|
||||
assert.strictEqual(chainRamBuildRowHex(0, row('push', { pushType: '04', pushIdent: '' })), '80EC0104');
|
||||
});
|
||||
|
||||
test('PUSH: CAT_TP link and TCP connection data fields (TS 102 226 9.2.2/9.2.3)', () => {
|
||||
// CAT_TP: transport level 3C (protocol type 00) + optional 39/36.
|
||||
assert.strictEqual(chainPushData({ pushType: '02', pushPort: '1F90' }), '3C03001F90');
|
||||
assert.strictEqual(
|
||||
chainPushData({ pushType: '02', pushPort: '1F90', pushSdu: '0200', pushIdent: 'ABCD' }),
|
||||
'3C03001F90390202003602ABCD');
|
||||
assert.strictEqual(chainPushData({ pushType: '02', pushPort: '' }), '');
|
||||
// TCP: bearer 35, transport 3C (protocol type 02), destination 3E, NAA 47.
|
||||
assert.strictEqual(
|
||||
chainPushData({ pushType: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet' }),
|
||||
'3501033C030200503E05210A0000014708696E7465726E6574');
|
||||
assert.strictEqual(
|
||||
chainPushData({ pushType: '03', pushPort: '0050', pushBearer: '350104' }),
|
||||
'3501043C03020050');
|
||||
_chains['chain-ram'] = { rows: [] };
|
||||
assert.strictEqual(
|
||||
chainRamBuildRowHex(0, row('push', { pushType: '02', pushPort: '1F90' })),
|
||||
'80EC0102053C03001F90');
|
||||
});
|
||||
|
||||
test('CREATE FILE FCP template skeleton (TS 102 222 table 4)', () => {
|
||||
_chains['chain-usim'] = { rows: [] };
|
||||
const r1 = row('create-file', { fid: '6F07', structure: 'transparent', fileSize: '0010', lcsi: '05' });
|
||||
_chains['chain-usim'].rows.push(r1);
|
||||
chainBuildFcp('chain-usim', 0, 'create');
|
||||
assert.strictEqual(r1.fields.fcp, '62138202012183026F078A01058C027F0080020010');
|
||||
const r2 = row('create-file', { fid: '6F3A', structure: 'linear_fixed', recLen: '006E', fileSize: '0226', lcsi: '05' });
|
||||
_chains['chain-usim'].rows.push(r2);
|
||||
chainBuildFcp('chain-usim', 1, 'create');
|
||||
assert.strictEqual(r2.fields.fcp, '621582040221006E83026F3A8A01058C027F0080020226');
|
||||
// Record files default the file size to one record.
|
||||
const r3 = row('create-file', { fid: '6F3A', structure: 'cyclic', recLen: '0010', fileSize: '' });
|
||||
_chains['chain-usim'].rows.push(r3);
|
||||
chainBuildFcp('chain-usim', 2, 'create');
|
||||
assert.ok(r3.fields.fcp.endsWith('8002' + '0010'));
|
||||
// RESIZE template: FID + file size only (table 17).
|
||||
const r4 = row('resize-file', { fid: '6F07', fileSize: '0010' });
|
||||
_chains['chain-usim'].rows.push(r4);
|
||||
chainBuildFcp('chain-usim', 3, 'resize');
|
||||
assert.strictEqual(r4.fields.fcp, '620883026F0780020010');
|
||||
});
|
||||
|
||||
test('pushSectionApdus builds the guided §9 channel/link requests', () => {
|
||||
// BIP channel opening: OPEN CHANNEL TLVs are optional.
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '350103' }), ['80EC010103350103']);
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '' }), ['80EC0101']);
|
||||
// CAT_TP: the destination port is mandatory (9.2.2).
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '02', pushPort: '1F90' }), ['80EC0102053C03001F90']);
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '02', pushPort: '' }), []);
|
||||
// TCP: port and destination address are mandatory (9.2.3).
|
||||
const tcp = '80EC010319' + '3501033C030200503E05210A0000014708696E7465726E6574';
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet' }),
|
||||
[tcp]);
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '03', pushPort: '0050' }), []);
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '03', pushAddr: '210A000001' }), []);
|
||||
// The identification packet (9.1.5) rides in the same message right after
|
||||
// the TCP request; its data is optional (ICCID when absent).
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet', withIdp: true }),
|
||||
[tcp, '80EC0104']);
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '03', pushPort: '0050', pushAddr: '210A000001', pushApn: 'internet', withIdp: true, idpIdent: '0102' }),
|
||||
[tcp, '80EC01040436020102']);
|
||||
// The follow-up only applies to the TCP request.
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withIdp: true }), ['80EC0101']);
|
||||
// Stepping Stones R7 18.6: the BIP channel opening and the CAT_TP link
|
||||
// request go in the same message; 01 carries the channel parameters, 02 the
|
||||
// CAT_TP port (mandatory) plus optional max SDU / identification data.
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '1F90' }),
|
||||
['80EC0101', '80EC0102053C03001F90']);
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '1F90', pushSdu: '0200', pushIdent: 'ABCD' }),
|
||||
['80EC0101', '80EC01020D3C03001F90390202003602ABCD']);
|
||||
// The CAT_TP port is mandatory for the pair, and the option only applies to 01.
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: true, pushPort: '' }), []);
|
||||
assert.deepStrictEqual(pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '', withCat: false, pushPort: '1F90' }), ['80EC0101']);
|
||||
});
|
||||
|
||||
test('pushOpenChannelTlvs composes the guided OPEN CHANNEL parameters', () => {
|
||||
// The bearer defaults to the default bearer (35 01 03); the local address is
|
||||
// never emitted (TS 102 226 9.2.1 / Stepping Stones 18.6.1).
|
||||
assert.strictEqual(pushOpenChannelTlvs({}), '350103');
|
||||
assert.strictEqual(pushOpenChannelTlvs({ ocBearer: '350104' }), '350104');
|
||||
// Transport level (UDP or TCP client remote only, TS 102 223 6.6.27.4).
|
||||
assert.strictEqual(pushOpenChannelTlvs({ ocProto: '01', ocPort: '1F90' }), '3501033C03011F90');
|
||||
assert.strictEqual(pushOpenChannelTlvs({ ocProto: '02', ocPort: '0050' }), '3501033C03020050');
|
||||
// Full set: transport level + destination address + NAA + buffer size +
|
||||
// transparent alpha (05 00 = no user confirmation).
|
||||
assert.strictEqual(
|
||||
pushOpenChannelTlvs({ ocProto: '01', ocPort: '1F90', ocAddr: '210A000001', ocApn: 'internet',
|
||||
ocBuffer: '0200', ocAlphaNull: true }),
|
||||
'350103' + '3C03011F90' + '3E05210A000001' + '4708696E7465726E6574' + '39020200' + '0500');
|
||||
// A custom alpha identifier and free-form extra TLVs (login/password, ...).
|
||||
assert.strictEqual(pushOpenChannelTlvs({ ocAlpha: '00410042' }), '350103050400410042');
|
||||
assert.strictEqual(pushOpenChannelTlvs({ ocExtra: '4708696E7465726E6574' }), '3501034708696E7465726E6574');
|
||||
// Fields mode feeds the same encoder as the raw hex field.
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '01', ocMode: 'fields', ocProto: '01', ocPort: '1F90' }),
|
||||
['80EC0101083501033C03011F90']);
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '01', ocMode: 'hex', pushData: '350103' }),
|
||||
['80EC010103350103']);
|
||||
// Fields mode plus the CAT_TP follow-up: the OPEN CHANNEL port is its own
|
||||
// field, the CAT_TP port comes from the shared one.
|
||||
assert.deepStrictEqual(
|
||||
pushSectionApdus('link', { request: '01', ocMode: 'fields', ocProto: '01', ocPort: '1F90',
|
||||
withCat: true, pushPort: '1F91' }),
|
||||
['80EC0101083501033C03011F90', '80EC0102053C03001F91']);
|
||||
});
|
||||
|
||||
test('chainApduList drops GET RESPONSE and splits multi-APDU rows', () => {
|
||||
_chains['ber-usim-1'] = { rows: [
|
||||
row('read-binary', { offset: '0000', le: '00' }),
|
||||
row('get-response', {}),
|
||||
row('select', { method: 'chain', chain: '3F00,2FE2' }),
|
||||
] };
|
||||
// Single APDUs pass through; the SELECT chain row is split by the parser.
|
||||
__split = [{ label: 'APDU', hex: '00A40000023F00' }, { label: 'APDU', hex: '00A40000022FE2' }];
|
||||
const list = chainApduList('ber-usim-1');
|
||||
assert.deepStrictEqual(list, ['00B0000000', '00A40000023F00', '00A40000022FE2']);
|
||||
});
|
||||
|
||||
test('an embedded chain contributes a C-APDU TLV to the script', () => {
|
||||
// genBerRowValue is covered in ber.test.js; here the chain-side contract:
|
||||
// one row per C-APDU, built with the same builders as the stock chains.
|
||||
_chains['ber-ram-9'] = { rows: [row('put-key', { keyVer: '01', keyId: '01', data: 'AA' })] };
|
||||
assert.strictEqual(chainBuildRowHex('ber-ram-9', 0), '80D0010101AA');
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
const asset = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
// The RFM SELECT file picker: merge of the card tree / custom files / the
|
||||
// shipped standard list, and the per-method fill logic (relative FID chains
|
||||
// per TS 102 221 11.1.1.2, path from MF without the MF identifier per
|
||||
// ISO 7816-4, path from the current DF per TS 102 221 11.1.1.2 P1=09).
|
||||
const FNS = ['chainKind', 'rfmRowKey', 'rfmParentPath', 'rfmEntryByPath', 'rfmRoots',
|
||||
'rfmFileEntries', 'rfmStartDf', 'rfmSetStartDf', 'rfmNoticeClear', 'rfmApplySelect',
|
||||
'rfmCurrentDf', 'rfmFidsFrom', 'rfmRelativeChain', 'rfmSelectFill', 'rfmFileOptionsHtml',
|
||||
'escHtml', 'esc', 'pysimFsNodePath', 'pysimCustomFid'];
|
||||
let code = '';
|
||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||
for (const c of ['const _chains = \\{\\};', 'let _uiccStdFiles = null;', 'let _uiccStdLoad = null;',
|
||||
'let _rfmShowAll = false;', 'const _rfmFilter = \\{\\};', 'const _rfmNotice = \\{\\};',
|
||||
'const _rfmStartDf = \\{\\};']) {
|
||||
const m = html.match(new RegExp(c));
|
||||
if (!m) throw new Error('declaration not found: ' + c);
|
||||
code += m[0].replace(/^(const|let) /, 'var ') + '\n';
|
||||
}
|
||||
code += 'var pysimCustomFiles = [];\n';
|
||||
code += 'var pysimFsTreeRoot = null;\n';
|
||||
code += 'function chainRender() {}\n';
|
||||
eval(code);
|
||||
|
||||
// The shipped asset stands in for the fetched standard list.
|
||||
_uiccStdFiles = asset.files;
|
||||
|
||||
function entry(p) {
|
||||
const e = _uiccStdFiles.find(f => f.path === p);
|
||||
assert.ok(e, 'asset entry missing: ' + p);
|
||||
return e;
|
||||
}
|
||||
|
||||
function row(cmd, fields) { return { cmd: cmd, fields: fields || {} }; }
|
||||
|
||||
function setChain(chainId, rows) {
|
||||
_chains[chainId] = { rows: rows || [] };
|
||||
return chainId;
|
||||
}
|
||||
|
||||
test('the shipped asset is well formed and covers the standard trees', () => {
|
||||
assert.ok(asset.files.length > 300, 'entries: ' + asset.files.length);
|
||||
const paths = asset.files.map(f => f.path);
|
||||
assert.strictEqual(paths.length, new Set(paths).size, 'duplicate paths');
|
||||
assert.strictEqual(entry('MF/2FE2').name, 'EF.ICCID');
|
||||
assert.strictEqual(entry('MF/7F10').name, 'DF.TELECOM');
|
||||
assert.strictEqual(entry('MF/7F10/6F40').name, 'EF.MSISDN');
|
||||
assert.strictEqual(entry('MF/7F20/6F46').name, 'EF.SPN');
|
||||
assert.strictEqual(entry('ADF.USIM/6F07').name, 'EF.IMSI');
|
||||
assert.strictEqual(entry('ADF.USIM').kind, 'adf');
|
||||
assert.strictEqual(entry('ADF.USIM').aid, 'A0000000871002');
|
||||
assert.strictEqual(entry('ADF.ISIM').aid, 'A0000000871004');
|
||||
});
|
||||
|
||||
test('rfmFidsFrom walks a path down to the FIDs', () => {
|
||||
const list = rfmFileEntries();
|
||||
assert.strictEqual(rfmFidsFrom(list, 'MF', 'MF/7F10/6F40'), '7F106F40');
|
||||
assert.strictEqual(rfmFidsFrom(list, 'MF/7F10', 'MF/7F10/6F40'), '6F40');
|
||||
assert.strictEqual(rfmFidsFrom(list, 'MF/7F10', 'MF/7F20'), '');
|
||||
assert.strictEqual(rfmFidsFrom(list, 'MF', 'ADF.USIM/6F07'), '');
|
||||
});
|
||||
|
||||
test('rfmRelativeChain uses the FID search order (children/parent/siblings)', () => {
|
||||
const list = rfmFileEntries();
|
||||
assert.strictEqual(rfmRelativeChain(list, 'MF', 'MF/7F10/6F40'), '7F10,6F40');
|
||||
// Sibling branch: from DF.TELECOM to EF.SPN under DF.GSM.
|
||||
assert.strictEqual(rfmRelativeChain(list, 'MF/7F10', 'MF/7F20/6F46'), '7F20,6F46');
|
||||
// One level up then sideways.
|
||||
assert.strictEqual(rfmRelativeChain(list, 'MF/7F10/5F3A', 'MF/7F20'), '7F10,7F20');
|
||||
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM', 'ADF.USIM/6F07'), '6F07');
|
||||
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM/5F3A', 'ADF.USIM/6F07'), '6F07');
|
||||
// Different roots: a FID select cannot cross into another tree.
|
||||
assert.strictEqual(rfmRelativeChain(list, 'MF', 'ADF.USIM/6F07'), '');
|
||||
assert.strictEqual(rfmRelativeChain(list, 'ADF.USIM', 'MF/2FE2'), '');
|
||||
});
|
||||
|
||||
test('rfmSelectFill keeps a method that can express the pick', () => {
|
||||
setChain('chain-sim', [row('select', { method: 'fid' })]);
|
||||
let fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'fid', fields: { fid: '7F10' }, switched: false });
|
||||
setChain('chain-sim', [row('select', { method: 'chain' })]);
|
||||
fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10/6F40'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'chain', fields: { chain: '7F10,6F40' }, switched: false });
|
||||
});
|
||||
|
||||
test('rfmSelectFill prefers path when the method has to switch', () => {
|
||||
// SIM RFM: path is P1=08 (from MF) only.
|
||||
setChain('chain-sim', [row('select', { method: 'fid' })]);
|
||||
let fill = rfmSelectFill('chain-sim', 0, entry('MF/7F10/6F40'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '7F106F40', base: 'mf' }, switched: true });
|
||||
// ADF entry cannot be reached from MF by FID/path/chain.
|
||||
assert.strictEqual(rfmSelectFill('chain-sim', 0, entry('ADF.USIM/6F07'), rfmFileEntries()), null);
|
||||
// USIM RFM in an ADF session (the picker defaults to MF for both
|
||||
// builders, so the ADF context is selected explicitly): ADF files are
|
||||
// direct children, deep ones use the relative path from the current DF.
|
||||
rfmSetStartDf('chain-usim', 'ADF.USIM');
|
||||
setChain('chain-usim', [row('select', { method: 'fid' })]);
|
||||
fill = rfmSelectFill('chain-usim', 0, entry('ADF.USIM/6F07'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'fid', fields: { fid: '6F07' }, switched: false });
|
||||
setChain('chain-usim', [row('select', { method: 'fid' })]);
|
||||
fill = rfmSelectFill('chain-usim', 0, entry('ADF.USIM/5F3A/4F22'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '5F3A4F22', base: 'df' }, switched: true });
|
||||
// Cross-root: an MF file is reachable from the ADF session by path from MF.
|
||||
setChain('chain-usim', [row('select', { method: 'fid' })]);
|
||||
fill = rfmSelectFill('chain-usim', 0, entry('MF/2FE2'), rfmFileEntries());
|
||||
assert.deepStrictEqual(fill, { method: 'path', fields: { path: '2FE2', base: 'mf' }, switched: true });
|
||||
});
|
||||
|
||||
test('rfmSelectFill falls back to chain when path cannot express it', () => {
|
||||
// Session in ADF.USIM, currently inside DF.PHONEBOOK: a target in the
|
||||
// DF.GSM-ACCESS branch is not under the current DF and not in the MF tree,
|
||||
// so only the relative chain works.
|
||||
const list = rfmFileEntries();
|
||||
rfmSetStartDf('chain-usim', 'ADF.USIM');
|
||||
setChain('chain-usim', [row('select', { method: 'path', path: '5F3A', base: 'df' })]);
|
||||
const fill = rfmSelectFill('chain-usim', 1, entry('ADF.USIM/5F3B/4F20'), list);
|
||||
assert.strictEqual(fill.method, 'chain');
|
||||
assert.strictEqual(fill.fields.chain, '5F3B,4F20');
|
||||
assert.strictEqual(fill.switched, true);
|
||||
// ADF roots are selected by AID, not by the picker.
|
||||
assert.strictEqual(rfmSelectFill('chain-usim', 1, entry('ADF.USIM'), list), null);
|
||||
});
|
||||
|
||||
test('rfmCurrentDf tracks the session context across rows', () => {
|
||||
const list = rfmFileEntries();
|
||||
setChain('chain-sim', [
|
||||
row('select', { method: 'fid', fid: '7F10' }),
|
||||
row('select', { method: 'fid', fid: '6F40' }),
|
||||
row('select', { method: 'path', path: '7F106F40' }),
|
||||
]);
|
||||
assert.strictEqual(rfmCurrentDf('chain-sim', 0, list), 'MF');
|
||||
assert.strictEqual(rfmCurrentDf('chain-sim', 1, list), 'MF/7F10');
|
||||
// Selecting an EF keeps the current DF.
|
||||
assert.strictEqual(rfmCurrentDf('chain-sim', 2, list), 'MF/7F10');
|
||||
// A path select sets the DF to the file's parent.
|
||||
assert.strictEqual(rfmCurrentDf('chain-sim', 3, list), 'MF/7F10');
|
||||
// The picker defaults to MF for both builders; an ADF session is explicit.
|
||||
delete _rfmStartDf['chain-usim'];
|
||||
assert.strictEqual(rfmStartDf('chain-usim'), 'MF');
|
||||
rfmSetStartDf('chain-usim', 'ADF.USIM');
|
||||
setChain('chain-usim', [
|
||||
row('select', { method: 'path', path: '5F3A', base: 'df' }),
|
||||
row('select', { method: 'chain', chain: '4F22' }),
|
||||
]);
|
||||
assert.strictEqual(rfmCurrentDf('chain-usim', 0, list), 'ADF.USIM');
|
||||
assert.strictEqual(rfmCurrentDf('chain-usim', 1, list), 'ADF.USIM/5F3A');
|
||||
assert.strictEqual(rfmCurrentDf('chain-usim', 2, list), 'ADF.USIM/5F3A');
|
||||
// The picker can start the session elsewhere (TAR decides the implicit DF).
|
||||
rfmSetStartDf('chain-sim', 'ADF.USIM');
|
||||
assert.strictEqual(rfmCurrentDf('chain-sim', 0, list), 'ADF.USIM');
|
||||
rfmSetStartDf('chain-sim', 'MF');
|
||||
});
|
||||
|
||||
test('rfmFileEntries merges card tree, custom files and the standard list', () => {
|
||||
const mf = { name: 'MF', fid: '3F00', isDir: true, parent: null, children: [] };
|
||||
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, parent: mf, exists: true, children: [] };
|
||||
const adn = { name: 'EF.ADN', fid: '6F3A', isDir: false, parent: gsm, exists: false, children: null };
|
||||
const iccid = { name: 'EF.ICCID', fid: '2FE2', isDir: false, parent: mf, exists: true, children: null };
|
||||
const customNode = { name: 'EF.MYFILE', fid: '6F99', isDir: false, parent: mf, custom: true, customPath: 'MF/6F99', exists: null, children: null };
|
||||
gsm.children.push(adn);
|
||||
mf.children.push(gsm, iccid, customNode);
|
||||
pysimFsTreeRoot = mf;
|
||||
pysimCustomFiles = [{ path: 'MF/6F99', name: 'EF.MYFILE', kind: 'ef' }];
|
||||
const list = rfmFileEntries();
|
||||
const byPath = {};
|
||||
list.forEach(e => { byPath[e.path] = e; });
|
||||
// The card tree wins over the standard entry with the same path and hides
|
||||
// probed-absent files.
|
||||
assert.strictEqual(byPath['MF/7F20'].source, 'card');
|
||||
assert.strictEqual(byPath['MF/2FE2'].exists, true);
|
||||
assert.ok(!byPath['MF/7F20/6F3A'], 'probed-absent file must be dropped');
|
||||
assert.strictEqual(byPath['MF/6F99'].source, 'custom');
|
||||
// The standard list still covers branches the card tree has not loaded.
|
||||
assert.ok(byPath['MF/7F10/6F40'], 'standard entry missing');
|
||||
assert.strictEqual(byPath['MF/7F10/6F40'].source, 'standard');
|
||||
// ADF roots are group headers, not entries.
|
||||
assert.ok(!list.some(e => e.kind === 'adf'));
|
||||
pysimFsTreeRoot = null;
|
||||
pysimCustomFiles = [];
|
||||
});
|
||||
|
||||
test('rfmFileOptionsHtml labels files with their path within the root', () => {
|
||||
const list = rfmFileEntries();
|
||||
rfmSetStartDf('chain-usim', 'ADF.USIM');
|
||||
setChain('chain-usim', [row('select', { method: 'fid' })]);
|
||||
const opts = rfmFileOptionsHtml('chain-usim', 0, list);
|
||||
// ADF.USIM session: EF.IMSI is a direct child of the root.
|
||||
assert.ok(opts.includes('>EF.IMSI — 6F07<'), opts.match(/>EF\.IMSI[^<]*</)[0]);
|
||||
// The root is the optgroup, the path is relative to it (FIDs in order).
|
||||
assert.ok(opts.includes('<optgroup label="ADF.USIM">'));
|
||||
assert.ok(!opts.includes('6F07 — '), 'no reversed "fid — parent" label');
|
||||
// Deep file under a DF: the label reads like the path field.
|
||||
rfmSetStartDf('chain-usim', 'MF');
|
||||
setChain('chain-sim', [row('select', { method: 'path', path: '7F10' })]);
|
||||
const deep = rfmFileOptionsHtml('chain-sim', 1, list);
|
||||
assert.ok(deep.includes('>EF.MSISDN — 7F10/6F40<'), deep.match(/>EF\.MSISDN[^<]*</)[0]);
|
||||
rfmSetStartDf('chain-usim', 'ADF.USIM');
|
||||
});
|
||||
|
||||
test('rfmFileOptionsHtml groups by root and marks method switches', () => {
|
||||
const list = rfmFileEntries();
|
||||
setChain('chain-sim', [row('select', { method: 'fid' })]);
|
||||
// Default: only what by-FID from MF can express (its direct children).
|
||||
const opts = rfmFileOptionsHtml('chain-sim', 0, list);
|
||||
assert.ok(opts.includes('<optgroup label="MF">'));
|
||||
assert.ok(opts.includes('MF/7F10'), 'DF.TELECOM is a direct child');
|
||||
assert.ok(!opts.includes('MF/7F10/6F40'), 'deep files need a switch');
|
||||
assert.ok(!opts.includes('<optgroup label="ADF.USIM">'), 'ADF files are not reachable from MF');
|
||||
// "all files" reveals the switchable entries, marked with the method they
|
||||
// would switch to; files in another root stay hidden (the session start
|
||||
// selector covers those).
|
||||
_rfmShowAll = true;
|
||||
const all = rfmFileOptionsHtml('chain-sim', 0, list);
|
||||
assert.ok(all.includes('MF/7F10/6F40'));
|
||||
assert.ok(all.includes('→ path'), 'switching entries are marked');
|
||||
assert.ok(!all.includes('<optgroup label="ADF.USIM">'));
|
||||
// Starting the session in the ADF makes its files pickable instead.
|
||||
_rfmShowAll = false;
|
||||
rfmSetStartDf('chain-sim', 'ADF.USIM');
|
||||
const adf = rfmFileOptionsHtml('chain-sim', 0, list);
|
||||
assert.ok(adf.includes('<optgroup label="ADF.USIM">'));
|
||||
assert.ok(adf.includes('ADF.USIM/6F07'), 'EF.IMSI is a direct child of ADF.USIM');
|
||||
rfmSetStartDf('chain-sim', 'MF');
|
||||
_rfmFilter['chain-sim#0'] = 'imsi';
|
||||
const filtered = rfmFileOptionsHtml('chain-sim', 0, list);
|
||||
assert.ok(!filtered.includes('EF.ADN'));
|
||||
delete _rfmFilter['chain-sim#0'];
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -3,16 +3,23 @@ requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-otaman-server"
|
||||
version = "1.9.28"
|
||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
||||
requires-python = ">=3.8"
|
||||
name = "pysim-simple-server"
|
||||
version = "3.32.0"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
license = { text = "GPL-2.0-or-later" }
|
||||
requires-python = ">=3.9"
|
||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||
# Declaring it here would make pip resolve its full tree (including the SMPP
|
||||
# bridge -> Twisted -> twisted-iocpsupport, which needs MSVC on Windows).
|
||||
|
||||
[project.scripts]
|
||||
pysim-otaman-server = "pysim_otaman_server.__main__:main"
|
||||
pysim-simple-server = "pysim_simple_server.__main__:main"
|
||||
|
||||
[tool.setuptools]
|
||||
license-files = ["LICENSE", "THIRD-PARTY-NOTICES"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["pysim_otaman_server*"]
|
||||
include = ["pysim_simple_server*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
pysim_simple_server = ["data/*.json", "data/*.LICENSE"]
|
||||
@@ -1,171 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from http.server import HTTPServer
|
||||
from pySim.card_handler import CardHandler
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.log import PySimLogger
|
||||
from pySim.cards import UiccCardBase
|
||||
|
||||
from .shell import load_pysim_app
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session
|
||||
|
||||
|
||||
_server_start = 0
|
||||
|
||||
def _log_stdout(msg):
|
||||
elapsed = time.time() - _server_start
|
||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||
|
||||
|
||||
def _default_web_dir():
|
||||
# <repo>/frontend, whether run from source or an editable install.
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
||||
|
||||
|
||||
def main():
|
||||
global _server_start
|
||||
_server_start = time.time()
|
||||
mod = load_pysim_app()
|
||||
parser = mod.option_parser
|
||||
parser.description = 'pysim-otaman-server — HTTP API for pysim'
|
||||
parser.add_argument('--http-host', default='127.0.0.1', help='Bind address (default: 127.0.0.1)')
|
||||
parser.add_argument('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
|
||||
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
|
||||
help='Directory with the OTAMan PWA static files to serve (default: <repo>/frontend)')
|
||||
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
|
||||
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
|
||||
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
|
||||
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
|
||||
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
|
||||
parser.add_argument('--terminal-profile', default='7FFFFFFFFF0000CF02', metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: 10-byte profile with SMS-PP download and event list)')
|
||||
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
|
||||
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
||||
parser.add_argument('--no-card-init', action='store_true', default=False,
|
||||
help='Skip pysim card initialization (preserve CAT session — no file manager)')
|
||||
|
||||
opts = parser.parse_args()
|
||||
opts.skip_card_init = opts.no_card_init
|
||||
sl = None
|
||||
scc = None
|
||||
card = None
|
||||
rs = None
|
||||
sim_menu = None
|
||||
event_list = None
|
||||
# Auto-detect PC/SC reader if none was explicitly specified.
|
||||
# Handles late pcscd startup and USB enumeration delays.
|
||||
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
||||
try:
|
||||
from smartcard.System import readers
|
||||
for attempt in range(3):
|
||||
r = readers()
|
||||
if r:
|
||||
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
||||
opts.pcsc_dev = 0
|
||||
break
|
||||
if attempt < 2:
|
||||
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass # smartcard module not available or pcscd unreachable
|
||||
|
||||
try:
|
||||
kwargs = {}
|
||||
if opts.apdu_trace:
|
||||
kwargs['apdu_tracer'] = _LoggingApduTracer()
|
||||
sl = mod.init_reader(opts, **kwargs)
|
||||
scc = SimCardCommands(sl)
|
||||
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
||||
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
||||
sl.wait_for_card(3)
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||
except Exception:
|
||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
ch = CardHandler(sl) if sl else None
|
||||
try:
|
||||
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
|
||||
except Exception:
|
||||
print("Warning: PysimApp creation failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
app = None
|
||||
if scc and hasattr(scc, '_tp'):
|
||||
scc._tp.apdu_tracer = _LoggingApduTracer()
|
||||
try:
|
||||
_init_proactive_session()
|
||||
sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla))
|
||||
sm, el = _send_terminal_profile(scc, opts.terminal_profile)
|
||||
sys.stderr.write('INIT: TP done, menu=%s events=%s\n' % ('yes' if sm else 'no', 'yes' if el else 'no'))
|
||||
sim_menu = sm or sim_menu
|
||||
event_list = el or event_list
|
||||
for _ in range(3):
|
||||
st_data, st_sw = _send_status(scc)
|
||||
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
||||
if not st_sw.startswith('91'):
|
||||
break
|
||||
_handle_proactive_chain(scc, st_sw)
|
||||
except Exception:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if app is not None and opts.apdu_trace:
|
||||
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
|
||||
# and drops the root level to INFO. Re-route pysim's own APDU trace logging
|
||||
# directly to fd 1 so it survives the app.stdout/StringIO redirection in the
|
||||
# HTTP handlers and the INFO level suppression.
|
||||
PySimLogger.setup(print_callback=_log_stdout)
|
||||
PySimLogger.set_level(logging.DEBUG)
|
||||
# PysimApp.__init__ and every `equip` wipe the transport apdu_tracer
|
||||
# (_onchange_apdu_trace sets it to None). Re-attach our tracer and make
|
||||
# sure it stays attached across equip/re-equip.
|
||||
tracer = _LoggingApduTracer()
|
||||
def _reattach_tracer():
|
||||
if app.card:
|
||||
app.card._scc._tp.apdu_tracer = tracer
|
||||
_reattach_tracer()
|
||||
orig_onchange = app._onchange_apdu_trace
|
||||
def _onchange_apdu_trace(param_name, old, new):
|
||||
orig_onchange(param_name, old, new)
|
||||
_reattach_tracer()
|
||||
app._onchange_apdu_trace = _onchange_apdu_trace
|
||||
server = HTTPServer((opts.http_host, opts.http_port), PysimHandler)
|
||||
server.sl = sl
|
||||
server.scc = scc
|
||||
server.card = card
|
||||
server.rs = rs
|
||||
server.app = app
|
||||
server.sms_oa = opts.sms_oa
|
||||
server.sms_sc = opts.sms_sm_sc
|
||||
server.log_requests = opts.log_requests
|
||||
server.terminal_profile = opts.terminal_profile
|
||||
server.web_dir = opts.web_dir
|
||||
server.sim_menu = sim_menu
|
||||
server.event_list = event_list
|
||||
server.menu_active = False
|
||||
server.stk_pending = None
|
||||
# Set server reference for polling timer and mark card as connected
|
||||
import pysim_otaman_server.server
|
||||
pysim_otaman_server.server._server_ref = server
|
||||
pysim_otaman_server.server._CARD_CONNECTED = True
|
||||
if opts.poll_interval is not None:
|
||||
pysim_otaman_server.server._set_poll_interval(opts.poll_interval)
|
||||
# Auto-enable polling if card initialized successfully (unless interval is 0)
|
||||
if server.scc and server.card and opts.poll_interval != 0:
|
||||
pysim_otaman_server.server._poll_enable()
|
||||
print("─" * 70)
|
||||
print(" pysim-otaman-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||
print(" Open http://%s:%s in your browser for the OTAMan UI (served by this server)."
|
||||
% (opts.http_host, opts.http_port))
|
||||
print("─" * 70)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pySim.card_handler import CardHandler
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.exceptions import NoCardError
|
||||
from pySim.log import PySimLogger
|
||||
from pySim.cards import UiccCardBase
|
||||
|
||||
from .shell import load_pysim_app
|
||||
from . import fastinit
|
||||
from . import gsmtap
|
||||
from . import presets
|
||||
from . import test_scripts
|
||||
from . import test_suites
|
||||
from . import testscript
|
||||
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _read_arr, _read_pbr, _netstate_read, _netstate_install, _LineFilter, _test_command_type, migrate_scripts_to_suites
|
||||
|
||||
|
||||
_server_start = 0
|
||||
|
||||
def _log_stdout(msg):
|
||||
elapsed = time.time() - _server_start
|
||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||
|
||||
|
||||
def _guard_console_streams():
|
||||
"""Never let a non-encodable character kill the server.
|
||||
|
||||
On Windows the launcher pipes stdout/stderr into PowerShell (see start.bat),
|
||||
so Python encodes with the ANSI code page instead of the wide console API -
|
||||
cp1251 cannot encode the banner's box-drawing rule (U+2500) or the APDU
|
||||
trace arrows (U+2192), and one such character raised UnicodeEncodeError and
|
||||
killed the process. errors="replace" keeps every message, whatever the code
|
||||
page (a character the code page lacks prints as '?')."""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _console_rule(n=70):
|
||||
"""The banner rule in the widest character the output stream can encode.
|
||||
|
||||
Under start.bat stdout is a pipe (not a console), so Python encodes with the
|
||||
ANSI code page - cp1251 (Russian Windows) cannot encode the box-drawing
|
||||
U+2500 and falls back to a plain ASCII rule (cp437 has the box-drawing
|
||||
characters and keeps the pretty one)."""
|
||||
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||
try:
|
||||
"\u2500".encode(enc)
|
||||
return "\u2500" * n
|
||||
except (UnicodeEncodeError, LookupError):
|
||||
return "-" * n
|
||||
|
||||
|
||||
def _default_web_dir():
|
||||
# <repo>/frontend, whether run from source or an editable install.
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
||||
|
||||
|
||||
def _default_mcc_mnc_list():
|
||||
# Bundled default: the MIT-licensed operator list ships with the package
|
||||
# (data/mcc-mnc-list.json, from pbakondy/mcc-mnc-list); --mcc-mnc-list
|
||||
# overrides it and the endpoint reports available=false when missing.
|
||||
pkg = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(pkg, 'data', 'mcc-mnc-list.json')
|
||||
|
||||
|
||||
def _build_http_server(host, port, handler):
|
||||
"""HTTP server for the PWA + API.
|
||||
|
||||
Threaded on purpose: card access is serialized by ``_CARD_LOCK`` (all
|
||||
POSTs, the card-touching GETs and the background threads take it), so
|
||||
while a long operation holds the lock the cached endpoints and static
|
||||
files must still be answerable - a single-threaded server starved the
|
||||
RAM modal's live progress poll (`/api/status.ram_progress`) and showed
|
||||
the whole install result only at the end. Daemon threads keep a stuck
|
||||
request from blocking shutdown."""
|
||||
return ThreadingHTTPServer((host, port), handler)
|
||||
|
||||
|
||||
def main():
|
||||
global _server_start
|
||||
_guard_console_streams()
|
||||
_server_start = time.time()
|
||||
mod = load_pysim_app()
|
||||
parser = mod.option_parser
|
||||
parser.description = 'pysim-simple-server — HTTP API for pysim'
|
||||
parser.add_argument('--http-host', default='127.0.0.1', help='Bind address (default: 127.0.0.1)')
|
||||
parser.add_argument('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
|
||||
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
|
||||
help='Directory with the SIMple PWA static files to serve (default: <repo>/frontend)')
|
||||
parser.add_argument('--mcc-mnc-list', default=_default_mcc_mnc_list(), metavar='PATH',
|
||||
help='Worldwide MCC/MNC operator list (JSON) for the network-simulation operator picker (default: the bundled data/mcc-mnc-list.json)')
|
||||
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
|
||||
parser.add_argument('--card-presets', default=None, metavar='PATH',
|
||||
help='Card preset store (default: ~/.pysim-simple-server/card_presets.json)')
|
||||
parser.add_argument('--test-scripts', default=None, metavar='PATH',
|
||||
help='Test script store (default: ~/.pysim-simple-server/test_scripts.json)')
|
||||
parser.add_argument('--test-suites', default=None, metavar='PATH',
|
||||
help='Test suite store (default: ~/.pysim-simple-server/test_suites.json)')
|
||||
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
|
||||
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
|
||||
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
|
||||
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
|
||||
parser.add_argument('--terminal-profile',
|
||||
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
|
||||
metavar='HEX',
|
||||
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
|
||||
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
|
||||
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
||||
parser.add_argument('--no-card-init', action='store_true', default=False,
|
||||
help='Skip pysim card initialization (preserve CAT session — no file manager)')
|
||||
parser.add_argument('--timing', action='store_true', default=False,
|
||||
help='Log phase durations, card resets and APDU counters with elapsed timestamps')
|
||||
parser.add_argument('--fast-init', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--full-pysim-init', action='store_true', default=False,
|
||||
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
||||
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
|
||||
help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
|
||||
parser.add_argument('--no-auto-equip', action='store_true', default=False,
|
||||
help='Do not automatically initialize a card right after it is inserted (default: auto-equip on)')
|
||||
parser.add_argument('--gsmtap', nargs='?', const=gsmtap.DEFAULT_TARGET, default=None,
|
||||
metavar='HOST[:PORT]',
|
||||
help='Stream every APDU (and the card ATR) as GSMTAP-SIM UDP packets for '
|
||||
'Wireshark / SIMtrace Analyser (default target: %s)' % gsmtap.DEFAULT_TARGET)
|
||||
|
||||
opts = parser.parse_args()
|
||||
opts.skip_card_init = opts.no_card_init
|
||||
opts.fast_init = not opts.full_pysim_init
|
||||
if opts.timing:
|
||||
_timing_on()
|
||||
if opts.menu_timeout is not None:
|
||||
_set_menu_timeout(opts.menu_timeout)
|
||||
set_auto_equip(not opts.no_auto_equip and not opts.skip_card_init)
|
||||
# APDU tracers: --apdu-trace (stderr) and --gsmtap (GSMTAP-SIM UDP) can be
|
||||
# combined; the fan-out keeps pySim's single-tracer interface.
|
||||
gsmtap_sender = None
|
||||
tracers = []
|
||||
if opts.apdu_trace:
|
||||
tracers.append(_LoggingApduTracer())
|
||||
if opts.gsmtap is not None:
|
||||
try:
|
||||
gsmtap_host, gsmtap_port = gsmtap.parse_target(opts.gsmtap)
|
||||
gsmtap_sender = gsmtap.GsmtapSender(gsmtap_host, gsmtap_port)
|
||||
tracers.append(gsmtap.GsmtapApduTracer(gsmtap_sender))
|
||||
sys.stderr.write('GSMTAP: streaming APDUs to %s\n' % gsmtap_sender.target)
|
||||
except (ValueError, OSError) as e:
|
||||
sys.stderr.write('GSMTAP: disabled (%s)\n' % e)
|
||||
tracer = None
|
||||
if len(tracers) == 1:
|
||||
tracer = tracers[0]
|
||||
elif tracers:
|
||||
tracer = gsmtap.FanoutApduTracer(tracers)
|
||||
sl = None
|
||||
scc = None
|
||||
card = None
|
||||
rs = None
|
||||
sim_menu = None
|
||||
event_list = None
|
||||
# Auto-detect PC/SC reader if none was explicitly specified.
|
||||
# Handles late pcscd startup and USB enumeration delays.
|
||||
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
||||
try:
|
||||
from smartcard.System import readers
|
||||
for attempt in range(3):
|
||||
r = readers()
|
||||
if r:
|
||||
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
||||
opts.pcsc_dev = 0
|
||||
break
|
||||
if attempt < 2:
|
||||
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
|
||||
time.sleep(2)
|
||||
except Exception:
|
||||
pass # smartcard module not available or pcscd unreachable
|
||||
|
||||
try:
|
||||
kwargs = {}
|
||||
if tracer is not None:
|
||||
kwargs['apdu_tracer'] = tracer
|
||||
t_phase = time.time()
|
||||
sl = mod.init_reader(opts, **kwargs)
|
||||
_tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
scc = SimCardCommands(sl)
|
||||
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
||||
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
||||
t_phase = time.time()
|
||||
if opts.fast_init:
|
||||
try:
|
||||
rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True)
|
||||
except NoCardError:
|
||||
# Normal cardless start: there was no card in the reader (the
|
||||
# 3s wait timed out). The presence monitor auto-equips once a
|
||||
# card appears; nothing to recover, no traceback.
|
||||
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||
except Exception:
|
||||
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
try:
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
except NoCardError:
|
||||
# The fallback retried the cardless wait; still a normal
|
||||
# cardless start, not an initialization failure.
|
||||
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||
else:
|
||||
try:
|
||||
sl.wait_for_card(3)
|
||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||
except NoCardError:
|
||||
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
if card is not None:
|
||||
# Fast init builds the card on its own SimCardCommands instance;
|
||||
# adopt it so server.scc carries the card's cla_byte/sel_ctrl (the
|
||||
# placeholder created above is a different object left at the SIM
|
||||
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
||||
scc = card._scc
|
||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||
except Exception:
|
||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
ch = CardHandler(sl) if sl else None
|
||||
t_phase = time.time()
|
||||
try:
|
||||
if card is not None:
|
||||
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
|
||||
else:
|
||||
# Cardless start: pySim logs 'Waiting for card...' (its own retry
|
||||
# path) and pySim-shell prints 'pySim-shell not equipped!'; we
|
||||
# report both cases with our own single line above. A PysimApp
|
||||
# without a card would also retry the cardless wait, so install
|
||||
# the filter before constructing it and drop the two internals.
|
||||
saved_stdout = sys.stdout
|
||||
sys.stdout = _LineFilter(saved_stdout, ('Waiting for card...', 'pySim-shell not equipped!'))
|
||||
try:
|
||||
app = mod.PysimApp(verbose=opts.verbose, card=None, rs=None, sl=sl, ch=ch)
|
||||
finally:
|
||||
sys.stdout = saved_stdout
|
||||
except Exception:
|
||||
print("Warning: PysimApp creation failed:", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
app = None
|
||||
_tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
if app is not None and opts.fast_init:
|
||||
fastinit.install(app)
|
||||
iccid = None
|
||||
netstate_files = None
|
||||
arr_files = None
|
||||
pbr_files = None
|
||||
if scc and card is not None and hasattr(scc, '_tp'):
|
||||
scc._tp.apdu_tracer = _LoggingApduTracer()
|
||||
try:
|
||||
_init_proactive_session()
|
||||
# Read EF.ICCID before the TERMINAL PROFILE starts the CAT session
|
||||
# (the PWA auto-selects the matching card preset from it).
|
||||
iccid = _read_iccid(app)
|
||||
if iccid:
|
||||
sys.stderr.write('INIT: ICCID %s\n' % iccid)
|
||||
# Network state monitor: read the network-related EFs in the
|
||||
# same CAT-free window (skipped without a readable ICCID).
|
||||
try:
|
||||
netstate_files = _netstate_read(app)
|
||||
except Exception:
|
||||
netstate_files = None
|
||||
# EF.ARR (the FCP '8B' references): read here too - a card
|
||||
# present at server start never runs _apply_equipped_card,
|
||||
# which owns this read on a later equip.
|
||||
arr_files = _read_arr(app)
|
||||
if arr_files:
|
||||
sys.stderr.write('INIT: EF.ARR %s\n' % ', '.join(
|
||||
'%s (%d rec)' % (a['fid'], a['num_records']) for a in arr_files))
|
||||
# EF.PBR (the phonebook reference record): same reason - the
|
||||
# startup init never runs _apply_equipped_card.
|
||||
pbr_files = _read_pbr(app)
|
||||
if pbr_files:
|
||||
sys.stderr.write('INIT: EF.PBR %s\n' % ', '.join(
|
||||
'%s (%d rec)' % (p['fid'], p['num_records']) for p in pbr_files))
|
||||
t_phase = time.time()
|
||||
sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla))
|
||||
sm, el = _send_terminal_profile(scc, opts.terminal_profile)
|
||||
sys.stderr.write('INIT: TP done, menu=%s events=%s\n' % ('yes' if sm else 'no', 'yes' if el else 'no'))
|
||||
sim_menu = sm or sim_menu
|
||||
event_list = el or event_list
|
||||
for _ in range(3):
|
||||
st_data, st_sw = _send_status(scc)
|
||||
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
||||
if not st_sw.startswith('91'):
|
||||
break
|
||||
_handle_proactive_chain(scc, st_sw)
|
||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||
except Exception:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if app is not None and tracer is not None:
|
||||
if opts.apdu_trace:
|
||||
# PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
|
||||
# and drops the root level to INFO. Re-route pysim's own APDU trace logging
|
||||
# directly to fd 1 so it survives the app.stdout/StringIO redirection in the
|
||||
# HTTP handlers and the INFO level suppression.
|
||||
PySimLogger.setup(print_callback=_log_stdout)
|
||||
PySimLogger.set_level(logging.DEBUG)
|
||||
# PysimApp.__init__ and every `equip` wipe the transport apdu_tracer
|
||||
# (_onchange_apdu_trace sets it to None). Re-attach our tracer(s) and make
|
||||
# sure they stay attached across equip/re-equip.
|
||||
def _reattach_tracer():
|
||||
if app.card:
|
||||
app.card._scc._tp.apdu_tracer = tracer
|
||||
_reattach_tracer()
|
||||
orig_onchange = app._onchange_apdu_trace
|
||||
def _onchange_apdu_trace(param_name, old, new):
|
||||
orig_onchange(param_name, old, new)
|
||||
_reattach_tracer()
|
||||
app._onchange_apdu_trace = _onchange_apdu_trace
|
||||
server = _build_http_server(opts.http_host, opts.http_port, PysimHandler)
|
||||
server.sl = sl
|
||||
server.scc = scc
|
||||
server.card = card
|
||||
server.rs = rs
|
||||
server.app = app
|
||||
# PC/SC recovery: recreate the transport after a service failure (pcscd
|
||||
# restart / reader re-enumeration); _ensure_transport() calls this.
|
||||
def _new_transport():
|
||||
return mod.init_reader(opts, **kwargs)
|
||||
server.transport_factory = _new_transport
|
||||
server.sms_oa = opts.sms_oa
|
||||
server.sms_sc = opts.sms_sm_sc
|
||||
server.log_requests = opts.log_requests
|
||||
server.terminal_profile = opts.terminal_profile
|
||||
server.cli_terminal_profile = opts.terminal_profile
|
||||
server.web_dir = opts.web_dir
|
||||
server.mcc_mnc_path = opts.mcc_mnc_list
|
||||
# Card presets live server-side (the SCP80 counter is card state the server
|
||||
# persists with the operation that consumed it); --card-presets overrides
|
||||
# the default store in the user's home.
|
||||
server.card_presets = presets.PresetStore(opts.card_presets)
|
||||
sys.stderr.write('PRESETS: %s (%d presets)\n'
|
||||
% (server.card_presets.path, len(server.card_presets.list())))
|
||||
# Test scripts live server-side too (only the server can run them):
|
||||
# --test-scripts overrides the default store, and the store validates a
|
||||
# script with the same engine the runner uses, so a stored script can
|
||||
# never fail at run start for a validation reason.
|
||||
server.test_scripts = test_scripts.TestScriptStore(
|
||||
opts.test_scripts,
|
||||
validator=lambda raw: testscript.normalise_script(raw, _test_command_type))
|
||||
sys.stderr.write('TESTSCRIPTS: %s (%d scripts)\n'
|
||||
% (server.test_scripts.path, len(server.test_scripts.list())))
|
||||
# Test suites group the scripts (v3.22.0): a script belongs to exactly one
|
||||
# suite, so a pre-suite store's scripts are attached to an auto-created
|
||||
# "Imported scripts" suite once (idempotent - no orphans, nothing to do).
|
||||
server.test_suites = test_suites.TestSuiteStore(opts.test_suites)
|
||||
sys.stderr.write('TESTSUITES: %s (%d suites)\n'
|
||||
% (server.test_suites.path, len(server.test_suites.list())))
|
||||
migrate_scripts_to_suites(server.test_scripts, server.test_suites)
|
||||
server.sim_menu = sim_menu
|
||||
server.event_list = event_list
|
||||
server.menu_active = False
|
||||
server.stk_pending = None
|
||||
server.card_present = card is not None
|
||||
server.card_session = 1 if card is not None else 0
|
||||
# The startup init inlines the post-equip work (it never runs
|
||||
# _apply_equipped_card): the batch worker's ready marker must be set
|
||||
# here too (v3.28.0 audit).
|
||||
server.card_ready_session = server.card_session
|
||||
server.iccid = iccid
|
||||
# EF.ARR cache (v3.24.0): read by the startup init above (a card present
|
||||
# at server start); a later equip refreshes it in _apply_equipped_card.
|
||||
server.arr = arr_files
|
||||
# EF.PBR cache (TS 31.102 4.4.2.1): read by the startup init above; a
|
||||
# later equip refreshes it in _apply_equipped_card.
|
||||
server.pbr = pbr_files
|
||||
# Network state monitor: install the state read during the startup init
|
||||
# (right after the ICCID, before the TERMINAL PROFILE). No readable
|
||||
# ICCID means the card is considered unusable - give up.
|
||||
try:
|
||||
_netstate_install(server, netstate_files if iccid else None)
|
||||
except Exception as e:
|
||||
server.net_state = None
|
||||
sys.stderr.write('INIT: network state failed: %s\n' % e)
|
||||
server.equipping = False
|
||||
# Set server reference for polling timer and mark the card session state
|
||||
import pysim_simple_server.server
|
||||
pysim_simple_server.server._server_ref = server
|
||||
pysim_simple_server.server._CARD_CONNECTED = card is not None
|
||||
if opts.poll_interval is not None:
|
||||
pysim_simple_server.server._set_poll_interval(opts.poll_interval)
|
||||
# Background STATUS polling is on by default: a CAT terminal polls during
|
||||
# idle (TS 102 221 14.6.2). --poll-interval 0 disables it, and a card
|
||||
# that wants no polling suspends it with POLLING OFF (TS 102 223 6.4.14)
|
||||
# until a new POLL INTERVAL.
|
||||
if opts.poll_interval == 0:
|
||||
pysim_simple_server.server._poll_disable()
|
||||
else:
|
||||
pysim_simple_server.server._poll_enable()
|
||||
# Start presence monitoring only after the startup init: pyscard reports an
|
||||
# already-present card as "added" on the first pass, and we must not
|
||||
# auto-equip over a session we just initialized. If startup init failed,
|
||||
# that event triggers auto-equip instead — the desired retry.
|
||||
if sl is not None and getattr(sl, '_reader', None) is not None:
|
||||
start_card_monitor(str(sl._reader))
|
||||
print(_console_rule())
|
||||
print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||
print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)."
|
||||
% (opts.http_host, opts.http_port))
|
||||
print(_console_rule())
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+1978
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
"""CAT/USAT item introduction releases (Stage 1 of the release guard).
|
||||
|
||||
Which proactive command, event, data object and PROVIDE LOCAL INFORMATION
|
||||
qualifier appeared in which release, curated from the change histories of the
|
||||
three pinned specifications:
|
||||
|
||||
* 3GPP TS 31.111 (USAT) V18.12.0, Annex W;
|
||||
* ETSI TS 102 223 (CAT) V18.3.0, Annex V;
|
||||
* ETSI TS 101 220 (TLVs/TARs) V18.3.0, Annex Q.
|
||||
|
||||
The version major equals the release (V7.x = Rel-7 ... V18.x = Rel-18). The
|
||||
human-readable reference with the per-row sources lives in the workspace at
|
||||
``docs/uicc/ETSI-3GPP-Releases.md``. Release 4 is the specification baseline
|
||||
(an item the histories never name); an item absent from this table is one the
|
||||
histories do not pin down - never filter on it.
|
||||
|
||||
The PWA mirrors this table (``CAT_RELEASE`` in frontend/index.html); the drift
|
||||
guard is tests/test_catrelease.py. Releases below 6 display as "pre-Rel-6"
|
||||
(``release_label``). ``DEFAULT_RELEASE`` (Rel-12) is the release a card
|
||||
preset's empty ``release`` resolves to (``preset_release``) - the safe level
|
||||
for cards whose release is not known.
|
||||
"""
|
||||
|
||||
# Keyed by: events - the event code (TS 102 223 8.25 / TS 31.111 7.5);
|
||||
# commands - the proactive command name; objects - 'tag name' as in the
|
||||
# reference document; pli - the PROVIDE LOCAL INFORMATION qualifier byte.
|
||||
CAT_RELEASE = {
|
||||
'events': {
|
||||
0x00: 4, 0x01: 4, 0x02: 4, 0x03: 4, 0x04: 4, 0x05: 4, 0x06: 4, 0x07: 4,
|
||||
0x08: 4, 0x09: 4, 0x0A: 4, 0x0B: 4, 0x0C: 4, 0x0D: 4, 0x0E: 4, 0x0F: 4,
|
||||
0x10: 6, 0x11: 7, 0x12: 8, 0x13: 9, 0x14: 12, 0x15: 9, 0x16: 9, 0x17: 10,
|
||||
0x18: 10, 0x19: 10, 0x1B: 11, 0x1C: 12, 0x1D: 14, 0x1E: 17, 0x1F: 16,
|
||||
},
|
||||
'commands': {
|
||||
'REFRESH': 4, 'MORE TIME': 4,
|
||||
'POLL INTERVAL': 4, 'POLLING OFF': 4,
|
||||
'SET UP EVENT LIST': 4, 'SET UP CALL': 4,
|
||||
'SEND SS': 4, 'SEND USSD': 4,
|
||||
'SEND SHORT MESSAGE': 4, 'SEND DTMF': 4,
|
||||
'LAUNCH BROWSER': 4, 'PLAY TONE': 4,
|
||||
'DISPLAY TEXT': 4, 'GET INKEY': 4,
|
||||
'GET INPUT': 4, 'SELECT ITEM': 4,
|
||||
'SET UP MENU': 4, 'PROVIDE LOCAL INFORMATION': 4,
|
||||
'TIMER MANAGEMENT': 4, 'SET UP IDLE MODE TEXT': 4,
|
||||
'RUN AT COMMAND': 4, 'LANGUAGE NOTIFICATION': 4,
|
||||
'OPEN CHANNEL': 4, 'CLOSE CHANNEL': 4,
|
||||
'SEND DATA': 4, 'RECEIVE DATA': 4,
|
||||
'GET CHANNEL STATUS': 4, 'SERVICE SEARCH': 4,
|
||||
'GET SERVICE INFORMATION': 4, 'DECLARE SERVICE': 4,
|
||||
'SET FRAMES': 6, 'GET FRAMES STATUS': 6,
|
||||
'RETRIEVE MULTIMEDIA MESSAGE': 7, 'SUBMIT MULTIMEDIA MESSAGE': 7,
|
||||
'DISPLAY MULTIMEDIA MESSAGE': 7, 'GEOGRAPHICAL LOCATION REQUEST': 8,
|
||||
'ACTIVATE': 13, 'CONTACTLESS STATE CHANGED': 9,
|
||||
'COMMAND CONTAINER': 10, 'ENCAPSULATED SESSION CONTROL': 11,
|
||||
'LSI COMMAND': 17,
|
||||
},
|
||||
'objects': {
|
||||
'06/86 Address': 4,
|
||||
'08/88 Subaddress': 4,
|
||||
'1C/9C Transaction identifier': 4,
|
||||
'1A/9A Cause': 4,
|
||||
'34/B4 Browser termination cause': 4,
|
||||
'35/B5 Bearer description': 4,
|
||||
'37/B7 Channel data length': 4,
|
||||
'38/B8 Channel status': 4,
|
||||
'3E/BE Other address (local / destination)': 4,
|
||||
'40/C0 Display parameters': 4,
|
||||
'64/E4 Browsing status': 4,
|
||||
'65/E5 Network search mode': 4,
|
||||
'67/E7 Frames Information': 6,
|
||||
'4B/CB (I-)WLAN Access Status': 7,
|
||||
'55/D5 CSG cell selection status': 9,
|
||||
'56/D6 CSG ID': 9,
|
||||
'57/D7 HNB name': 9,
|
||||
'31/B1 IMS URI': 10,
|
||||
'76/F6 IARI': 10,
|
||||
'77/F7 IMPU list': 11,
|
||||
'78/F8 IMS status code': 11,
|
||||
'7E/FE Media Type': 12,
|
||||
'73/F3 URI truncated': 12,
|
||||
'09/89 PLMN ID': 4,
|
||||
'3F/BF Access technology': 4,
|
||||
'B4 Supported Radio Access Technologies': 13,
|
||||
'9D Data connection status': 14,
|
||||
'AA Data connection type': 14,
|
||||
'AE (E/5G)SM cause': 14,
|
||||
'C7 Network Access Name': 14,
|
||||
'0B/8B PDP/PDN/PDU type': 14,
|
||||
'26/A6 Date-Time and Time zone': 4,
|
||||
'13/93 Location Information': 4,
|
||||
'55/D5 CAG cell selection status': 17,
|
||||
'56/D6 CAG information list': 17,
|
||||
'57/D7 CAG Human-readable network name list': 17,
|
||||
'55/D5 Slices status': 16,
|
||||
'56/D6 Slices information (served)': 16,
|
||||
'78/F8 Allowed slices information': 16,
|
||||
'77/F7 Allowed slices with S-NSSAI mapping': 18,
|
||||
'D7 Rejected slices with S-NSSAI mapping': 18,
|
||||
'B1 Rejected slices information': 18,
|
||||
'79/F9 Partial NSSAI': 18,
|
||||
'70/F0 Last Envelope': 18,
|
||||
},
|
||||
'pli': {
|
||||
'00': 4, '01': 4, '02': 4, '03': 4, '04': 4, '06': 4, '07': 4, '08': 6,
|
||||
'09': 4, '0A': 4, '0D': 8, '0E': 12, '0F': 10, '10': 10, '11': 9, '12': 10,
|
||||
'13': 10, '14': 13, '15': 16, '16': 17, '17': 18, '1A': 13,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def release_label(rel):
|
||||
"""The display label of an introduction release.
|
||||
|
||||
Everything below Rel-6 collapses to "pre-Rel-6" (the UI convention);
|
||||
0/None yields '' (unknown - the item is never filtered).
|
||||
"""
|
||||
if not rel:
|
||||
return ''
|
||||
return 'pre-Rel-6' if rel < 6 else 'Rel-%d' % rel
|
||||
|
||||
|
||||
def item_release(kind, key, default=None):
|
||||
"""The introduction release of an item, or `default` when unknown."""
|
||||
return CAT_RELEASE.get(kind, {}).get(key, default)
|
||||
|
||||
|
||||
# The release a preset's empty `release` resolves to: Rel-12 is the safe level
|
||||
# for cards whose release is not known (the CAT items the tool sends stay
|
||||
# within what a modern card implements). Mirrored in the PWA
|
||||
# (`CAT_RELEASE_DEFAULT`), drift-guarded by tests/test_catrelease.py.
|
||||
DEFAULT_RELEASE = 12
|
||||
|
||||
|
||||
def preset_release(preset):
|
||||
"""The effective CAT release of a card preset.
|
||||
|
||||
The preset's own `release` when it names one (4-18), else the default.
|
||||
Empty or invalid values resolve to the default - an unset release must
|
||||
never filter anything away.
|
||||
"""
|
||||
try:
|
||||
rel = int((preset or {}).get('release') or 0)
|
||||
except (TypeError, ValueError):
|
||||
rel = 0
|
||||
return rel if 4 <= rel <= 18 else DEFAULT_RELEASE
|
||||
@@ -0,0 +1,36 @@
|
||||
mcc-mnc-list.json — worldwide MCC/MNC operator list (incl. MVNO entries)
|
||||
|
||||
Source: https://github.com/pbakondy/mcc-mnc-list
|
||||
mcc-mnc-list.json, commit 97bc165252bb9bb5b37946898ec556c29a38feb8
|
||||
(2023-04-04); retrieved 2026-09-20
|
||||
md5 e63af9bc0beb651575a457d53c2e6fdd
|
||||
Data: derived from the Wikipedia article "Mobile country code"
|
||||
(https://en.wikipedia.org/wiki/Mobile_country_code)
|
||||
|
||||
The Network simulation operator picker hides entries whose `bands` field
|
||||
marks them as MVNO (they do not operate their own radio network); the full
|
||||
list is kept so the Network state panel can still resolve operator names.
|
||||
|
||||
--- upstream license -------------------------------------------------------
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Peter Bakondy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
# coding=utf-8
|
||||
"""eSIM / LPA local operations (ES10a/b/c) on the equipped eUICC.
|
||||
|
||||
Scope: chip details (EID, EUICCInfo1/2, configured addresses), profile
|
||||
listing with metadata (GetProfilesInfo), profile switching (Enable/Disable)
|
||||
and a read-only notification viewer (ListNotification). No profile
|
||||
downloads, no notification processing/removal and no SM-DP+ interaction.
|
||||
|
||||
Every command goes through pySim's ES10 static API
|
||||
(``CardApplicationISDR.store_data_tlv`` / ``get_eid``) on the card's own
|
||||
logical channel; the ISD-R is selected first and the previous selection is
|
||||
restored afterwards. The caller holds ``_CARD_LOCK``.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from osmocom.tlv import BER_TLV_IE, bertlv_parse_one_rawtag, flatten_dict_lists
|
||||
from pySim.euicc import (
|
||||
AID_ISD_R, CardApplicationISDR, DisableProfileReq, DisableProfileResp,
|
||||
EnableProfileReq, EnableProfileResp, EuiccConfiguredAddresses, EuiccInfo1,
|
||||
EuiccInfo2, Iccid, IsdpAid, ListNotificationReq, ListNotificationResp,
|
||||
ProfileIdentifier, ProfileInfo, ProfileInfoListReq, ProfileInfoListResp,
|
||||
RefreshFlag, TagList,
|
||||
)
|
||||
from pySim.utils import b2h
|
||||
|
||||
|
||||
class EsimError(Exception):
|
||||
"""eSIM operation failed; ``code`` is a stable identifier for the API."""
|
||||
|
||||
def __init__(self, code, message=None):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
|
||||
|
||||
# ES10c result codes -> short English fallback (the PWA localizes by code).
|
||||
RESULT_MESSAGES = {
|
||||
'ok': 'ok',
|
||||
'iccidOrAidNotFound': 'profile not found',
|
||||
'profileNotInDisabledState': 'profile is not disabled',
|
||||
'profileNotInEnabledState': 'profile is not enabled',
|
||||
'disallowedByPolicy': 'disallowed by policy',
|
||||
'wrongProfileReenabling': 'wrong profile re-enabling',
|
||||
'catBusy': 'card is busy with a CAT session',
|
||||
'undefinedError': 'undefined error',
|
||||
}
|
||||
|
||||
|
||||
def is_euicc(app):
|
||||
"""True when the equipped card runs an eUICC profile (SGP.02/22/32)."""
|
||||
rs = getattr(app, 'rs', None)
|
||||
return 'eUICC' in str(getattr(rs, 'profile', '') or '')
|
||||
|
||||
|
||||
def _select_isdr(app):
|
||||
"""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, app)
|
||||
return lchan.scc
|
||||
|
||||
|
||||
def _restore(app):
|
||||
"""Return to MF so later server operations start from a known selection.
|
||||
|
||||
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. The shell app
|
||||
is passed as cmd_app so the command-set bookkeeping follows the selection."""
|
||||
try:
|
||||
app.rs.soft_reset(app)
|
||||
except Exception as e:
|
||||
sys.stderr.write('ESIM: selection restore failed: %s\n' % e)
|
||||
|
||||
|
||||
def _run(app, fn):
|
||||
scc = _select_isdr(app)
|
||||
try:
|
||||
return fn(scc)
|
||||
finally:
|
||||
_restore(app)
|
||||
|
||||
|
||||
def _normalize(value):
|
||||
"""bytes -> hex string, recursively; scalars pass through."""
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return b2h(bytes(value)).upper()
|
||||
if isinstance(value, dict):
|
||||
return {k: _normalize(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _flatten(resp):
|
||||
"""ES10 response object -> flat dict (single root key unwrapped)."""
|
||||
if resp is None:
|
||||
return {}
|
||||
flat = flatten_dict_lists(resp.to_dict())
|
||||
if len(flat) == 1:
|
||||
flat = next(iter(flat.values()))
|
||||
return _normalize(flat)
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
"""A repeated TLV child decodes to a dict for one entry, a list for many."""
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _repeated(container, key):
|
||||
"""Collect `key` children from a container that may itself be a dict or a
|
||||
list of dicts (flatten_dict_lists collapses repeated parents either way)."""
|
||||
out = []
|
||||
for item in _as_list(container):
|
||||
if isinstance(item, dict):
|
||||
out.extend(_as_list(item.get(key)))
|
||||
return out
|
||||
|
||||
|
||||
def _transceive(app, cmd_do, resp_cls):
|
||||
return _run(app, lambda scc: CardApplicationISDR.store_data_tlv(scc, cmd_do, resp_cls))
|
||||
|
||||
|
||||
def _error_text(result):
|
||||
if isinstance(result, int):
|
||||
return RESULT_MESSAGES.get('undefinedError')
|
||||
return RESULT_MESSAGES.get(result, result)
|
||||
|
||||
|
||||
# ---- SGP.22 EUICCInfo decoding -------------------------------------------
|
||||
#
|
||||
# pySim's EuiccInfo1/2 classes are incomplete (the capability fields are raw
|
||||
# GreedyBytes, several SGP.22 TLVs are missing from the class), so the chip
|
||||
# endpoint requests the raw TLVs and decodes them here, per SGP.22 v2.6
|
||||
# §5.7.8/§5.7.13 and cross-checked against lpac's euicc/es10c_ex.c. Unknown
|
||||
# TLVs are preserved in `raw_tlvs`, never dropped.
|
||||
|
||||
UICC_CAPABILITY_BITS = [
|
||||
'contactlessSupport', 'usimSupport', 'isimSupport', 'csimSupport',
|
||||
'akaMilenage', 'akaCave', 'akaTuak128', 'akaTuak256', 'rfu1', 'rfu2',
|
||||
'gbaAuthenUsim', 'gbaAuthenISim', 'mbmsAuthenUsim', 'eapClient',
|
||||
'javacard', 'multos', 'multipleUsimSupport', 'multipleIsimSupport',
|
||||
'multipleCsimSupport', 'berTlvFileSupport', 'dfLinkSupport', 'catTp',
|
||||
'getIdentity', 'profile-a-x25519', 'profile-b-p256', 'suciCalculatorApi',
|
||||
]
|
||||
RSP_CAPABILITY_BITS = [
|
||||
'additionalProfile', 'crlSupport', 'rpmSupport', 'testProfileSupport',
|
||||
'deviceInfoExtensibilitySupport', 'serviceSpecificDataSupport',
|
||||
]
|
||||
PPR_ID_BITS = ['pprUpdateControl', 'ppr1', 'ppr2', 'ppr3']
|
||||
PPR_FLAG_BITS = ['consentRequired']
|
||||
TRE_PROPERTY_BITS = ['isDiscrete', 'isIntegrated', 'usesRemoteMemory']
|
||||
EUICC_CATEGORIES = {0: 'other', 1: 'basicEuicc', 2: 'mediumEuicc',
|
||||
3: 'contactlessEuicc'}
|
||||
|
||||
|
||||
class _GetRatRequest(BER_TLV_IE, tag=0xbf43):
|
||||
"""ES10b GetRat request (no input data, SGP.22 §5.7.13)."""
|
||||
|
||||
|
||||
def _tlvs(data):
|
||||
"""Walk a BER-TLV buffer -> [(tag, value)]; multi-byte tags kept raw."""
|
||||
out = []
|
||||
rest = bytes(data or b'')
|
||||
while rest:
|
||||
tag, _length, value, rest = bertlv_parse_one_rawtag(rest)
|
||||
out.append((tag, value))
|
||||
return out
|
||||
|
||||
|
||||
def _tlv_value(data, tag):
|
||||
"""Value bytes of the first `tag` TLV in `data` (b'' when absent)."""
|
||||
for t, value in _tlvs(data):
|
||||
if t == tag:
|
||||
return value
|
||||
return b''
|
||||
|
||||
|
||||
def _decode_version(data):
|
||||
"""VersionType: major/minor/revision bytes -> 'M.m.r'."""
|
||||
if len(data) != 3:
|
||||
return None
|
||||
return '%d.%d.%d' % (data[0], data[1], data[2])
|
||||
|
||||
|
||||
def _decode_bit_string(data, names):
|
||||
"""ASN.1 BIT STRING content -> list of set bit names.
|
||||
|
||||
The first octet is the number of unused bits in the final octet; bits are
|
||||
numbered MSB-first within each octet (SGP.22 v2.6 §5.7.8)."""
|
||||
if not data:
|
||||
return []
|
||||
unused = data[0]
|
||||
body = data[1:]
|
||||
out = []
|
||||
for j, byte in enumerate(body):
|
||||
b = byte
|
||||
if j == len(body) - 1 and unused:
|
||||
b &= ~(0xFF >> (8 - unused)) & 0xFF
|
||||
for i in range(8):
|
||||
idx = j * 8 + i
|
||||
if idx >= len(names):
|
||||
break
|
||||
if b & 0x80:
|
||||
out.append(names[idx])
|
||||
b = (b << 1) & 0xFF
|
||||
return out
|
||||
|
||||
|
||||
def _decode_ski_list(data):
|
||||
"""SEQUENCE OF SubjectKeyIdentifier -> hex strings."""
|
||||
return [value.hex().upper() for _tag, value in _tlvs(data)]
|
||||
|
||||
|
||||
def _decode_ext_card_resource(data):
|
||||
"""ETSI TS 102 226 Extended Card Resource Information (inner 81/82/83)."""
|
||||
out = {}
|
||||
raw = {}
|
||||
for tag, value in _tlvs(data):
|
||||
if tag == 0x81:
|
||||
out['installed_application'] = int.from_bytes(value, 'big')
|
||||
elif tag == 0x82:
|
||||
out['free_non_volatile_memory'] = int.from_bytes(value, 'big')
|
||||
elif tag == 0x83:
|
||||
out['free_volatile_memory'] = int.from_bytes(value, 'big')
|
||||
else:
|
||||
raw['%02X' % tag] = value.hex().upper()
|
||||
if raw:
|
||||
out['raw_tlvs'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _decode_certification_data_object(data):
|
||||
"""CertificationDataObject (SGP.22 v2.6 §5.7.8): platform label + DLOA URL."""
|
||||
out = {}
|
||||
raw = {}
|
||||
for tag, value in _tlvs(data):
|
||||
if tag == 0x80:
|
||||
out['platform_label'] = value.decode('utf-8', 'replace')
|
||||
elif tag == 0x81:
|
||||
out['discovery_base_url'] = value.decode('utf-8', 'replace')
|
||||
else:
|
||||
raw['%02X' % tag] = value.hex().upper()
|
||||
if raw:
|
||||
out['raw_tlvs'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _decode_info1(raw_hex):
|
||||
"""EUICCInfo1 (BF20): SVN and the CI PKI lists."""
|
||||
out = {'svn': None, 'euicc_ci_pki_list_for_verification': [],
|
||||
'euicc_ci_pki_list_for_signing': []}
|
||||
raw = {}
|
||||
for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF20)):
|
||||
if tag == 0x82:
|
||||
out['svn'] = _decode_version(value)
|
||||
elif tag == 0xA9:
|
||||
out['euicc_ci_pki_list_for_verification'] = _decode_ski_list(value)
|
||||
elif tag == 0xAA:
|
||||
out['euicc_ci_pki_list_for_signing'] = _decode_ski_list(value)
|
||||
else:
|
||||
raw['%02X' % tag] = value.hex().upper()
|
||||
if raw:
|
||||
out['raw_tlvs'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _decode_info2(raw_hex):
|
||||
"""EUICCInfo2 (BF22) with every SGP.22 v2.6 field decoded."""
|
||||
out = {}
|
||||
raw = {}
|
||||
for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF22)):
|
||||
if tag == 0x81:
|
||||
out['profile_version'] = _decode_version(value)
|
||||
elif tag == 0x82:
|
||||
out['svn'] = _decode_version(value)
|
||||
elif tag == 0x83:
|
||||
out['euicc_firmware_ver'] = _decode_version(value)
|
||||
elif tag == 0x84:
|
||||
out['ext_card_resource'] = _decode_ext_card_resource(value)
|
||||
elif tag == 0x85:
|
||||
out['uicc_capability'] = _decode_bit_string(value, UICC_CAPABILITY_BITS)
|
||||
elif tag == 0x86:
|
||||
out['ts102241_version'] = _decode_version(value)
|
||||
elif tag == 0x87:
|
||||
out['globalplatform_version'] = _decode_version(value)
|
||||
elif tag == 0x88:
|
||||
out['rsp_capability'] = _decode_bit_string(value, RSP_CAPABILITY_BITS)
|
||||
elif tag == 0xA9:
|
||||
out['euicc_ci_pki_list_for_verification'] = _decode_ski_list(value)
|
||||
elif tag == 0xAA:
|
||||
out['euicc_ci_pki_list_for_signing'] = _decode_ski_list(value)
|
||||
elif tag in (0x8B, 0xAB): # implicit and explicit category encodings
|
||||
out['euicc_category'] = EUICC_CATEGORIES.get(
|
||||
int.from_bytes(value, 'big') if value else 0, 'other')
|
||||
elif tag == 0x99:
|
||||
out['forbidden_profile_policy_rules'] = _decode_bit_string(value, PPR_ID_BITS)
|
||||
elif tag == 0x04: # ppVersion has no context tag
|
||||
out['pp_version'] = _decode_version(value)
|
||||
elif tag == 0x0C: # sasAcreditationNumber is a bare UTF8String
|
||||
out['ss_acreditation_number'] = value.decode('utf-8', 'replace')
|
||||
elif tag == 0xAC:
|
||||
out['certification_data_object'] = _decode_certification_data_object(value)
|
||||
elif tag == 0xAD:
|
||||
out['tre_properties'] = _decode_bit_string(value, TRE_PROPERTY_BITS)
|
||||
elif tag == 0xAE:
|
||||
out['tre_product_reference'] = value.decode('utf-8', 'replace')
|
||||
elif tag == 0xAF:
|
||||
out['additional_euicc_profile_package_versions'] = [
|
||||
_decode_version(v) for _t, v in _tlvs(value)]
|
||||
else:
|
||||
raw['%02X' % tag] = value.hex().upper()
|
||||
if raw:
|
||||
out['raw_tlvs'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _decode_addresses(raw_hex):
|
||||
"""ES10a GetEuiccConfiguredAddresses (BF3C)."""
|
||||
out = {'default_dp_address': None, 'root_ds_address': None}
|
||||
raw = {}
|
||||
for tag, value in _tlvs(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF3C)):
|
||||
if tag == 0x80:
|
||||
out['default_dp_address'] = value.decode('utf-8', 'replace')
|
||||
elif tag == 0x81:
|
||||
out['root_ds_address'] = value.decode('utf-8', 'replace')
|
||||
else:
|
||||
raw['%02X' % tag] = value.hex().upper()
|
||||
if raw:
|
||||
out['raw_tlvs'] = raw
|
||||
return out
|
||||
|
||||
|
||||
def _decode_rat(raw_hex):
|
||||
"""ES10b GetRat (BF43): the Rules Authorisation Table (SGP.22 §5.7.13)."""
|
||||
out = []
|
||||
table = _tlv_value(_tlv_value(bytes.fromhex(raw_hex or ''), 0xBF43), 0xA0)
|
||||
for _tag, rule in _tlvs(table):
|
||||
entry = {'ppr_ids': [], 'allowed_operators': [], 'ppr_flags': []}
|
||||
for tag, value in _tlvs(rule):
|
||||
if tag == 0x80:
|
||||
entry['ppr_ids'] = _decode_bit_string(value, PPR_ID_BITS)
|
||||
elif tag == 0xA1:
|
||||
operators = []
|
||||
for _t, op in _tlvs(value):
|
||||
ident = {'plmn': None, 'gid1': None, 'gid2': None}
|
||||
for t2, v2 in _tlvs(op):
|
||||
if t2 == 0x80:
|
||||
ident['plmn'] = v2.hex().upper()
|
||||
elif t2 == 0x81:
|
||||
ident['gid1'] = v2.hex().upper()
|
||||
elif t2 == 0x82:
|
||||
ident['gid2'] = v2.hex().upper()
|
||||
operators.append(ident)
|
||||
entry['allowed_operators'] = operators
|
||||
elif tag == 0x82:
|
||||
entry['ppr_flags'] = _decode_bit_string(value, PPR_FLAG_BITS)
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _raw_request(scc, cmd_cls):
|
||||
"""Raw response hex of a request TLV (no pySim response decoding)."""
|
||||
return CardApplicationISDR.store_data_tlv(scc, cmd_cls(), None)
|
||||
|
||||
|
||||
def chip_info(app):
|
||||
"""EID (ES10c GetEuiccData), EUICCInfo1/2, configured addresses and RAT."""
|
||||
out = {'eid': None, 'info1': None, 'info2': None, 'addresses': None,
|
||||
'rat': None, 'errors': {}}
|
||||
scc = _select_isdr(app)
|
||||
try:
|
||||
parts = (
|
||||
('eid', lambda: CardApplicationISDR.get_eid(scc)),
|
||||
('info1', lambda: _decode_info1(_raw_request(scc, EuiccInfo1))),
|
||||
('info2', lambda: _decode_info2(_raw_request(scc, EuiccInfo2))),
|
||||
('addresses', lambda: _decode_addresses(
|
||||
_raw_request(scc, EuiccConfiguredAddresses))),
|
||||
('rat', lambda: _decode_rat(_raw_request(scc, _GetRatRequest))),
|
||||
)
|
||||
for key, fn in parts:
|
||||
try:
|
||||
out[key] = fn()
|
||||
except Exception as e: # one unsupported part must not fail the rest
|
||||
out['errors'][key] = str(e)
|
||||
finally:
|
||||
_restore(app)
|
||||
return out
|
||||
|
||||
|
||||
def _profile_tag_list():
|
||||
"""TagList requesting every ProfileInfo tag (pySim-shell's --all set)."""
|
||||
tags = [nest.tag for nest in ProfileInfo.nested_collection_cls().nested]
|
||||
u8 = []
|
||||
for tag in tags:
|
||||
if tag <= 255:
|
||||
u8.append(tag)
|
||||
elif tag <= 65535:
|
||||
u8.append(tag >> 8)
|
||||
u8.append(tag & 0xff)
|
||||
return TagList(decoded=u8)
|
||||
|
||||
|
||||
def profiles(app):
|
||||
"""ES10c GetProfilesInfo: the profile list with its metadata."""
|
||||
resp = _transceive(app, ProfileInfoListReq(children=[_profile_tag_list()]),
|
||||
ProfileInfoListResp)
|
||||
flat = _flatten(resp)
|
||||
err = flat.get('profile_info_list_error')
|
||||
if err is not None:
|
||||
return {'profiles': [], 'error': _error_text(err)}
|
||||
seq = flat.get('profile_info_seq')
|
||||
out = []
|
||||
for p in _repeated(seq, 'profile_info'):
|
||||
owner = p.get('profile_owner')
|
||||
icon = p.get('icon')
|
||||
out.append({
|
||||
'iccid': p.get('iccid'),
|
||||
'isdp_aid': p.get('isdp_aid'),
|
||||
'state': p.get('profile_state'),
|
||||
'nickname': p.get('profile_nickname'),
|
||||
'provider': p.get('service_provider_name'),
|
||||
'name': p.get('profile_name'),
|
||||
'class': p.get('profile_class'),
|
||||
'icon_type': p.get('icon_type'),
|
||||
'icon': icon,
|
||||
'icon_size': len(icon) // 2 if icon else None,
|
||||
'owner': owner.get('profile_owner_plmn') if isinstance(owner, dict) else None,
|
||||
})
|
||||
return {'profiles': out, 'error': None}
|
||||
|
||||
|
||||
# ProfileMgmtOperation flags in bit order (SGP.22 §5.7.9). The TLV carries a
|
||||
# padding-bits octet followed by the flags octet, so a TLV parsed from the
|
||||
# card nests the flags under 'pmo' while an object built from decoded flags
|
||||
# carries them directly.
|
||||
PROFILE_MGMT_OPERATIONS = ('install', 'enable', 'disable', 'delete')
|
||||
|
||||
|
||||
def _profile_operations(op):
|
||||
"""ProfileMgmtOperation flags -> operation names ([] when unknown)."""
|
||||
if not isinstance(op, dict):
|
||||
return []
|
||||
flags = op.get('pmo') if isinstance(op.get('pmo'), dict) else op
|
||||
return [name for name in PROFILE_MGMT_OPERATIONS if flags.get(name)]
|
||||
|
||||
|
||||
def notifications(app):
|
||||
"""ES10b ListNotification: read-only list of pending notifications."""
|
||||
resp = _transceive(app, ListNotificationReq(), ListNotificationResp)
|
||||
flat = _flatten(resp)
|
||||
err = flat.get('list_notifications_result_error')
|
||||
if err is not None:
|
||||
return {'notifications': [], 'error': _error_text(err)}
|
||||
lst = flat.get('notification_metadata_list')
|
||||
out = []
|
||||
for n in _repeated(lst, 'notification_metadata'):
|
||||
out.append({
|
||||
'seq_number': n.get('seq_number'),
|
||||
'operations': _profile_operations(n.get('profile_mgmt_operation')),
|
||||
'address': n.get('notification_address'),
|
||||
'iccid': n.get('iccid'),
|
||||
})
|
||||
return {'notifications': out, 'error': None}
|
||||
|
||||
|
||||
def build_switch_apdu(action, iccid=None, isdp_aid=None, refresh=True):
|
||||
"""STORE DATA APDU hex for ES10c Enable/DisableProfile (no sending)."""
|
||||
if action not in ('enable', 'disable'):
|
||||
raise EsimError('bad_action')
|
||||
ident = []
|
||||
if isdp_aid:
|
||||
aid = re.sub(r'[^0-9a-fA-F]', '', str(isdp_aid))
|
||||
if not aid:
|
||||
raise EsimError('bad_aid')
|
||||
ident.append(IsdpAid(decoded=bytes.fromhex(aid)))
|
||||
elif iccid:
|
||||
digits = re.sub(r'[^0-9a-fA-F]', '', str(iccid))
|
||||
if digits[-1:] in ('f', 'F'):
|
||||
digits = digits[:-1]
|
||||
if not digits.isdigit():
|
||||
raise EsimError('bad_iccid')
|
||||
ident.append(Iccid(decoded=digits))
|
||||
else:
|
||||
raise EsimError('missing_profile')
|
||||
flag = RefreshFlag(decoded=1 if refresh else 0)
|
||||
req_cls = EnableProfileReq if action == 'enable' else DisableProfileReq
|
||||
tx_do = req_cls(children=[ProfileIdentifier(children=ident), flag]).to_tlv()
|
||||
return '80E29100%02x%s00' % (len(tx_do), tx_do.hex().upper())
|
||||
|
||||
|
||||
def parse_switch_response(action, data_hex):
|
||||
"""STORE DATA response TLV -> {'ok', 'result', 'message'}."""
|
||||
resp_cls = EnableProfileResp if action == 'enable' else DisableProfileResp
|
||||
key = 'enable_result' if action == 'enable' else 'disable_result'
|
||||
result = None
|
||||
if data_hex:
|
||||
resp = resp_cls()
|
||||
resp.from_tlv(bytes.fromhex(data_hex))
|
||||
result = _flatten(resp).get(key)
|
||||
return {'ok': result == 'ok',
|
||||
'result': result if isinstance(result, str) else 'undefinedError',
|
||||
'message': _error_text(result)}
|
||||
|
||||
|
||||
def switch_profile(app, send_apdu, run_chain, action, iccid=None,
|
||||
isdp_aid=None, refresh=True):
|
||||
"""Run an ES10c Enable/DisableProfile switch on the ISD-R.
|
||||
|
||||
``send_apdu(apdu_hex) -> (data_hex, sw)`` performs one raw STORE DATA and
|
||||
``run_chain(sw91)`` answers the proactive command(s) the card sends
|
||||
alongside the switch (True when a REFRESH was answered). The ISD-R is
|
||||
selected first and the previous selection restored afterwards, like the
|
||||
other ES10 functions.
|
||||
|
||||
With the refresh flag set the ISD-R returns OK *before* the REFRESH
|
||||
(SGP.22 v2.6 §5.7.16/§5.7.17 step 6) and the switch completes upon the
|
||||
TERMINAL RESPONSE or the following RESET (step 8). A 91XX status is that
|
||||
OK: the STORE DATA is never retried (the mid-switch card answers 6985 to
|
||||
the retry) and the caller re-initializes the card afterwards."""
|
||||
apdu = build_switch_apdu(action, iccid, isdp_aid, refresh)
|
||||
_select_isdr(app)
|
||||
try:
|
||||
data, sw = send_apdu(apdu)
|
||||
if sw == '9000':
|
||||
out = parse_switch_response(action, data)
|
||||
out['refresh_seen'] = False
|
||||
return out
|
||||
if sw and sw.startswith('91'):
|
||||
refresh_seen = False
|
||||
try:
|
||||
refresh_seen = bool(run_chain(sw))
|
||||
except Exception as e:
|
||||
sys.stderr.write('ESIM: REFRESH chain failed: %s\n' % e)
|
||||
return {'ok': True, 'result': 'ok', 'message': 'ok',
|
||||
'refresh_seen': refresh_seen}
|
||||
return {'ok': False, 'result': 'undefinedError', 'sw': sw,
|
||||
'message': 'SW %s' % sw}
|
||||
finally:
|
||||
_restore(app)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Event-data builders for the test scripts' semantic ``event`` action.
|
||||
|
||||
The PWA's Phone tab builds the same objects client-side (``EVENT_FORMS``);
|
||||
this module is the server-side mirror for the events a test script can send
|
||||
semantically. Every other event goes through the raw ``envelope`` action
|
||||
(the Phone form shows its built hex, which can be pasted into ``data``).
|
||||
|
||||
Field sets and defaults follow the PWA's forms and the pinned specs
|
||||
(TS 31.111 7.5.x, TS 102 223 8.x); the byte vectors are pinned in both the
|
||||
Node and the Python suites so the two implementations cannot drift apart.
|
||||
|
||||
Supported events:
|
||||
|
||||
* ``0x03`` Location status - status + optional Location information (8.19,
|
||||
normal service only).
|
||||
* ``0x0B`` Access technology change - the 8.61 single-access-technology byte.
|
||||
* ``0x12`` Network rejection - registration type, access technology, cause,
|
||||
the location area (LAI/RAI/TAI by registration group), the optional
|
||||
extended EMM cause and the extended information object.
|
||||
* ``0x1D`` Data connection status change - status/type/transaction id plus
|
||||
the optional cause, host-clock date-time (8.39), location information,
|
||||
access technology, location status, APN and PDP/PDN/PDU type.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
EVENT_LOCATION_STATUS = 0x03
|
||||
EVENT_ACCESS_TECH = 0x0B
|
||||
EVENT_NETWORK_REJECTION = 0x12
|
||||
EVENT_DATA_CONNECTION = 0x1D
|
||||
|
||||
# The single-ENVELOPE budget of the event data (TS 102 223: the inner data is
|
||||
# capped at 252 bytes, 8 of them the event-list + device-identities header;
|
||||
# chained delivery is not implemented). server.py's _EVENT_DATA_MAX and the
|
||||
# PWA's EVENT_INNER_MAX mirror this value.
|
||||
EVENT_DATA_MAX = 244
|
||||
|
||||
# The event code from a script's `event` value (a hex byte, or one of these
|
||||
# names - the PWA's display names and short keywords).
|
||||
EVENT_NAMES = {
|
||||
'location status': EVENT_LOCATION_STATUS,
|
||||
'location_status': EVENT_LOCATION_STATUS,
|
||||
'location-status': EVENT_LOCATION_STATUS,
|
||||
'access technology change': EVENT_ACCESS_TECH,
|
||||
'access_tech': EVENT_ACCESS_TECH,
|
||||
'access-technology-change': EVENT_ACCESS_TECH,
|
||||
'network rejection': EVENT_NETWORK_REJECTION,
|
||||
'network_rejection': EVENT_NETWORK_REJECTION,
|
||||
'network-rejection': EVENT_NETWORK_REJECTION,
|
||||
'data connection status change': EVENT_DATA_CONNECTION,
|
||||
'data_connection': EVENT_DATA_CONNECTION,
|
||||
'data-connection-status-change': EVENT_DATA_CONNECTION,
|
||||
}
|
||||
|
||||
|
||||
class EventError(ValueError):
|
||||
"""Invalid event parameters - a user-facing message."""
|
||||
|
||||
|
||||
def resolve_event(value):
|
||||
"""The event code of a script's `event` value (a hex byte or a name), or
|
||||
None when it is neither."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if 0 <= value <= 0xFF else None
|
||||
s = str(value or '').strip()
|
||||
if not s:
|
||||
return None
|
||||
if re.fullmatch(r'(0x)?[0-9A-Fa-f]{1,2}', s):
|
||||
return int(s, 16)
|
||||
return EVENT_NAMES.get(s.lower())
|
||||
|
||||
|
||||
def event_name(code):
|
||||
"""A display name for an event code (the short keyword)."""
|
||||
for name, c in (('location_status', EVENT_LOCATION_STATUS),
|
||||
('access_tech', EVENT_ACCESS_TECH),
|
||||
('network_rejection', EVENT_NETWORK_REJECTION),
|
||||
('data_connection', EVENT_DATA_CONNECTION)):
|
||||
if c == code:
|
||||
return name
|
||||
return '0x%02X' % code
|
||||
|
||||
|
||||
# ─── field helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_DIGITS_RE = re.compile(r'^\d+$')
|
||||
|
||||
|
||||
def _int(fields, key, default, lo, hi):
|
||||
v = fields.get(key)
|
||||
if v in (None, ''):
|
||||
return default
|
||||
text = str(v).strip()
|
||||
try:
|
||||
n = int(text, 16) if text.lower().startswith('0x') else int(text, 10)
|
||||
except ValueError:
|
||||
raise EventError('%s must be an integer' % key)
|
||||
if not lo <= n <= hi:
|
||||
raise EventError('%s must be %d..%d' % (key, lo, hi))
|
||||
return n
|
||||
|
||||
|
||||
def _hex(fields, key, default, lo_len, hi_len):
|
||||
v = re.sub(r'\s', '', str(fields.get(key) or default or '')).upper()
|
||||
if len(v) % 2 or not (lo_len <= len(v) <= hi_len) or \
|
||||
not re.fullmatch(r'[0-9A-F]+', v):
|
||||
raise EventError('%s must be %d-%d hex digits'
|
||||
% (key, lo_len, hi_len))
|
||||
return v
|
||||
|
||||
|
||||
def _digits(fields, key, default, lo, hi):
|
||||
v = str(fields.get(key) or default or '').strip()
|
||||
if not _DIGITS_RE.match(v) or not lo <= len(v) <= hi:
|
||||
raise EventError('%s must be %d-%d digits' % (key, lo, hi))
|
||||
return v
|
||||
|
||||
|
||||
def plmn_hex(mcc, mnc):
|
||||
"""MCC/MNC -> the 3-byte TS 24.008 BCD PLMN (the PWA's encPlmn: MCC
|
||||
digits swapped, MNC padded to 3 digits with 'F')."""
|
||||
d = mcc.ljust(3, '0')[:3] + mnc.ljust(3, 'F')[:3]
|
||||
return (d[1] + d[0] + d[5] + d[2] + d[4] + d[3]).upper()
|
||||
|
||||
|
||||
def _local_now():
|
||||
return time.localtime()
|
||||
|
||||
|
||||
def _tz_offset_minutes():
|
||||
"""Minutes east of UTC for the host clock."""
|
||||
try:
|
||||
return int(time.localtime().tm_gmtoff // 60)
|
||||
except AttributeError: # Windows
|
||||
return -int(time.timezone // 60)
|
||||
|
||||
|
||||
def scts_bytes(st, tz_min):
|
||||
"""The 7-byte TS 123 040 TP-SCTS (8.39 Date-Time and Time zone): swapped
|
||||
semi-octet BCD digits, the time zone in quarters of an hour with the sign
|
||||
in bit 3."""
|
||||
semi = lambda v: ((v % 10) << 4) | (v // 10)
|
||||
off = int(tz_min)
|
||||
tz = semi(int(round(abs(off) / 15.0)) & 0xFF) | (0x08 if off < 0 else 0)
|
||||
return bytes([semi(st.tm_year % 100), semi(st.tm_mon), semi(st.tm_mday),
|
||||
semi(st.tm_hour), semi(st.tm_min), semi(st.tm_sec), tz])
|
||||
|
||||
|
||||
# ─── the builders ───────────────────────────────────────────────────────
|
||||
|
||||
def build_location_status(fields):
|
||||
"""TS 31.111 7.5.2: status + Location information (8.19) for normal
|
||||
service only."""
|
||||
status = _int(fields, 'status', 0, 0, 2)
|
||||
out = '9B01%02X' % status
|
||||
if status == 0 and fields.get('mcc') not in (None, ''):
|
||||
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
lac = _hex(fields, 'lac', '0000', 4, 4)
|
||||
cell = _hex(fields, 'cell', '0001', 4, 4)
|
||||
out += '9307' + plmn_hex(mcc, mnc) + lac + cell
|
||||
return out
|
||||
|
||||
|
||||
def build_access_tech(fields):
|
||||
"""TS 102 223 8.61: the single access technology byte."""
|
||||
tech = _int(fields, 'tech', 0, 0, 255)
|
||||
return 'BF01%02X' % tech
|
||||
|
||||
|
||||
def build_network_rejection(fields):
|
||||
"""TS 31.111 7.5.5 (the PWA form's build): registration type selects the
|
||||
location object - LAI for the location-updating group, RAI for GPRS, TAI
|
||||
for EPS/5GS."""
|
||||
rt = _int(fields, 'reg_type', 0x00, 0x00, 0x11)
|
||||
access_tech = _int(fields, 'access_tech', 0x00, 0, 255)
|
||||
cause = _int(fields, 'cause', 0x02, 0, 255)
|
||||
group = 'lu' if rt <= 0x02 else ('gprs' if rt <= 0x08 else 'eps')
|
||||
mcc = _digits(fields, 'mcc', '250', 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
plmn = plmn_hex(mcc, mnc)
|
||||
out = 'F401%02X' % rt
|
||||
out += 'BF01%02X' % access_tech
|
||||
out += 'F501%02X' % cause
|
||||
if group == 'lu':
|
||||
out += '9305' + plmn + _hex(fields, 'lac', '0000', 4, 4)
|
||||
elif group == 'gprs':
|
||||
out += 'F306' + plmn + _hex(fields, 'lac', '0000', 4, 4) \
|
||||
+ _hex(fields, 'rac', '01', 2, 2)
|
||||
else:
|
||||
tac = _hex(fields, 'tac', '0001', 4, 6)
|
||||
out += '7D0%X' % (3 + len(tac) // 2) + plmn + tac
|
||||
if fields.get('ext_cause') not in (None, ''):
|
||||
out += '5701%02X' % _int(fields, 'ext_cause', 0, 0, 255)
|
||||
ext_type = fields.get('ext_info_type')
|
||||
if ext_type not in (None, ''):
|
||||
ext = _hex(fields, 'ext_info', '', 2, 20)
|
||||
out += 'F2%02X%02X%s' % (1 + len(ext) // 2, _int(fields, 'ext_info_type', 0, 0, 255), ext)
|
||||
return out
|
||||
|
||||
|
||||
def build_data_connection(fields, now=None):
|
||||
"""TS 31.111 7.5.25.2 object order; the conditional objects are omitted
|
||||
when left empty. ``now`` (a struct_time) makes the date-time testable."""
|
||||
status = _int(fields, 'status', 0, 0, 2)
|
||||
dtype = _int(fields, 'type', 0, 0, 2)
|
||||
out = '9D01%02X' % status
|
||||
out += 'AA01%02X' % dtype
|
||||
if fields.get('cause') not in (None, ''):
|
||||
out += 'AE01%02X' % _int(fields, 'cause', 0, 0, 255)
|
||||
ti = str(fields.get('ti') or '00').strip().upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', ti):
|
||||
raise EventError('ti must be one hex byte')
|
||||
out += '1C01' + ti
|
||||
if fields.get('datetime') == 'now':
|
||||
out += '2607' + scts_bytes(now or _local_now(), _tz_offset_minutes()).hex().upper()
|
||||
if fields.get('mcc') not in (None, ''):
|
||||
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
out += '1307' + plmn_hex(mcc, mnc) \
|
||||
+ _hex(fields, 'lac', '0000', 4, 4) + _hex(fields, 'cell', '0001', 4, 4)
|
||||
if fields.get('tech') not in (None, ''):
|
||||
out += 'BF01%02X' % _int(fields, 'tech', 0, 0, 255)
|
||||
out += '9B01%02X' % _int(fields, 'loc_status', 0, 0, 2)
|
||||
if str(fields.get('apn') or '').strip():
|
||||
body = str(fields['apn']).encode('utf-8')
|
||||
if len(body) > 100:
|
||||
raise EventError('apn must be at most 100 bytes')
|
||||
out += 'C7%02X%s' % (len(body), body.hex().upper())
|
||||
if fields.get('pdp_type') not in (None, ''):
|
||||
out += '0B01%02X' % _int(fields, 'pdp_type', 0, 0, 255)
|
||||
return out
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
EVENT_LOCATION_STATUS: build_location_status,
|
||||
EVENT_ACCESS_TECH: build_access_tech,
|
||||
EVENT_NETWORK_REJECTION: build_network_rejection,
|
||||
EVENT_DATA_CONNECTION: build_data_connection,
|
||||
}
|
||||
|
||||
|
||||
def normalise(event, fields):
|
||||
"""Validate an event's fields (returning the cleaned dict) - the same
|
||||
function the builder runs, so a script fails before the run starts."""
|
||||
fields = dict(fields or {})
|
||||
if event not in BUILDERS:
|
||||
raise EventError('no semantic builder for event 0x%02X - use the '
|
||||
'envelope action with the data hex' % event)
|
||||
BUILDERS[event](fields)
|
||||
return fields
|
||||
|
||||
|
||||
def build(event, fields, now=None):
|
||||
"""The event-data hex for a semantic event action."""
|
||||
fields = normalise(event, fields)
|
||||
if event == EVENT_DATA_CONNECTION:
|
||||
return BUILDERS[event](fields, now=now)
|
||||
return BUILDERS[event](fields)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Fast card initialization for pysim-simple-server.
|
||||
|
||||
pySim's ``init_card()`` performs several physical card resets: one per profile
|
||||
candidate tried by ``CardProfile.pick()`` plus one at the end of
|
||||
``RuntimeState.__init__``, and ``PysimApp.equip()`` resets yet again. On common
|
||||
readers each disconnect/connect costs around a second, so the stock path spends
|
||||
most of its time re-establishing a clean state (MF selected) that can also be
|
||||
restored in software.
|
||||
|
||||
This module mirrors ``pySim.app.init_card()`` with those resets removed: all
|
||||
profile probes run back-to-back on the same connection and the runtime state
|
||||
uses a software reset. It is the default init/equip path; ``--full-pysim-init``
|
||||
restores pysim's stock behavior, and the explicit ``equip``/``reset`` commands
|
||||
keep a real reconnect/physical reset.
|
||||
"""
|
||||
|
||||
import operator
|
||||
import sys
|
||||
|
||||
from smartcard.Exceptions import CardConnectionException, NoCardException
|
||||
|
||||
from pySim.cards import CardBase, SimCardBase, UiccCardBase, card_detect
|
||||
from pySim.commands import SimCardCommands
|
||||
from pySim.exceptions import ProtocolError, SwMatchError
|
||||
from pySim.filesystem import CardApplication, CardModel
|
||||
from pySim.profile import CardProfile
|
||||
from pySim.runtime import RuntimeState
|
||||
from pySim.ts_102_221 import CardProfileUICC
|
||||
from pySim.utils import all_subclasses
|
||||
|
||||
import pySim.euicc
|
||||
|
||||
from .server import _tlog
|
||||
|
||||
|
||||
class FastRuntimeState(RuntimeState):
|
||||
"""RuntimeState whose reset() restores software state (selects MF) instead
|
||||
of power-cycling the card. Use hard_reset() for an explicit reset."""
|
||||
|
||||
def reset(self, cmd_app=None):
|
||||
try:
|
||||
return self.soft_reset(cmd_app)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-RESET: soft reset failed (%s), falling back to physical reset\n' % e)
|
||||
return self.hard_reset(cmd_app)
|
||||
|
||||
def soft_reset(self, cmd_app=None):
|
||||
for lchan_nr in list(self.lchan.keys()):
|
||||
self.lchan[lchan_nr].scc.scp = None
|
||||
if lchan_nr == 0:
|
||||
continue
|
||||
del self.lchan[lchan_nr]
|
||||
self.adm_verified = False
|
||||
try:
|
||||
atr = self.card._scc.get_atr()
|
||||
except Exception:
|
||||
atr = None
|
||||
if cmd_app:
|
||||
cmd_app.lchan = self.lchan[0]
|
||||
self.lchan[0].select('MF', cmd_app)
|
||||
self.lchan[0].selected_adf = None
|
||||
self.identity['ATR'] = atr
|
||||
return atr
|
||||
|
||||
def hard_reset(self, cmd_app=None):
|
||||
return super().reset(cmd_app)
|
||||
|
||||
|
||||
def pick_profile_no_reset(scc):
|
||||
"""Like CardProfile.pick(), but without a physical reset between
|
||||
candidates. Each probe selects its own discriminating file, so a reset only
|
||||
costs a reconnect without changing the outcome."""
|
||||
original_reset = scc.reset_card
|
||||
scc.reset_card = lambda: None
|
||||
try:
|
||||
profiles = sorted(all_subclasses(CardProfile), key=operator.attrgetter('ORDER'))
|
||||
for p in profiles:
|
||||
if p.match_with_card(scc):
|
||||
return p()
|
||||
return None
|
||||
finally:
|
||||
scc.reset_card = original_reset
|
||||
|
||||
|
||||
def _restore_mf_after_probe(scc):
|
||||
"""Probing can leave an ADF (e.g. ISD-R on an eUICC) selected. The
|
||||
RuntimeState construction selects MF by FID, which some cards refuse from
|
||||
within an ADF (6A82), so restore it here: the cheap select keeps the
|
||||
reset-free path for normal cards, the physical reset covers the rest."""
|
||||
try:
|
||||
scc.select_file('3f00')
|
||||
except SwMatchError:
|
||||
sys.stderr.write('FAST-INIT: MF restore after probing failed; physical reset\n')
|
||||
scc.reset_card()
|
||||
|
||||
|
||||
def init_card_fast(sl, skip_card_init=False, wait=True):
|
||||
"""Replacement for pySim.app.init_card() that avoids redundant resets.
|
||||
|
||||
``wait`` performs the single disconnect/connect of this init (explicit
|
||||
equip passes True; startup already connects via wait_for_card). If probing
|
||||
leaves the card in a state the software reset cannot clear, retry once
|
||||
after a physical reset."""
|
||||
try:
|
||||
return _init_card_once(sl, skip_card_init, wait)
|
||||
except (SwMatchError, ProtocolError) as e:
|
||||
sys.stderr.write('FAST-INIT: %s; retrying after physical reset\n' % e)
|
||||
sl.reset_card()
|
||||
return _init_card_once(sl, skip_card_init, wait=False)
|
||||
except (CardConnectionException, NoCardException) as e:
|
||||
# PC/SC link error during the first exchange (e.g. the card was
|
||||
# swapped a moment ago): release the connection, wait for the card
|
||||
# and retry once.
|
||||
sys.stderr.write('FAST-INIT: link error (%s); retrying after reconnect\n' % e)
|
||||
try:
|
||||
sl.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
return _init_card_once(sl, skip_card_init, wait=True)
|
||||
|
||||
|
||||
def _init_card_once(sl, skip_card_init, wait):
|
||||
scc = SimCardCommands(transport=sl)
|
||||
if wait:
|
||||
sl.wait_for_card(3)
|
||||
if skip_card_init:
|
||||
return None, CardBase(scc)
|
||||
|
||||
generic_card = False
|
||||
card = card_detect(scc)
|
||||
if card is None:
|
||||
card = SimCardBase(scc)
|
||||
generic_card = True
|
||||
|
||||
profile = pick_profile_no_reset(scc)
|
||||
if profile is None:
|
||||
return None, card
|
||||
|
||||
# A successful probe may leave an ADF selected (e.g. ISD-R on an eUICC);
|
||||
# RuntimeState selects MF by FID and would fail on cards that refuse that
|
||||
# from within an ADF.
|
||||
_restore_mf_after_probe(scc)
|
||||
|
||||
if generic_card and isinstance(profile, CardProfileUICC):
|
||||
card._adm_chv_num = 0x0A
|
||||
|
||||
if isinstance(profile, CardProfileUICC):
|
||||
for app_cls in all_subclasses(CardApplication):
|
||||
if hasattr(app_cls, '_' + app_cls.__name__ + '__intermediate'):
|
||||
continue
|
||||
profile.add_application(app_cls())
|
||||
if generic_card:
|
||||
card = UiccCardBase(scc)
|
||||
|
||||
rs = FastRuntimeState(card, profile)
|
||||
|
||||
CardModel.apply_matching_models(scc, rs)
|
||||
|
||||
sl.set_sw_interpreter(rs)
|
||||
|
||||
isd_r = rs.mf.applications.get(pySim.euicc.AID_ISD_R.lower(), None)
|
||||
if isd_r:
|
||||
rs.lchan[0].select_file(isd_r)
|
||||
try:
|
||||
rs.identity['EID'] = pySim.euicc.CardApplicationISDR.get_eid(scc)
|
||||
except SwMatchError:
|
||||
pass
|
||||
finally:
|
||||
# rs.reset() tries the reset-free soft reset first and escalates
|
||||
# to a physical reset when MF cannot be selected from the ADF.
|
||||
rs.reset()
|
||||
|
||||
return rs, card
|
||||
|
||||
|
||||
def do_equip_fast(app):
|
||||
"""Explicit equip: one real reconnect (wait_for_card) then reset-free init.
|
||||
PysimApp.equip() unregisters the old command sets itself after the new init
|
||||
succeeds, so a failed init leaves the previous card state intact."""
|
||||
rs, card = init_card_fast(app.sl, wait=True)
|
||||
app.equip(card, rs)
|
||||
|
||||
|
||||
def do_reset_fast(app):
|
||||
"""Explicit reset: always a physical card reset."""
|
||||
if app.rs is None:
|
||||
app.card._scc.reset_card()
|
||||
atr = app.card._scc.get_atr()
|
||||
else:
|
||||
atr = app.rs.hard_reset(app)
|
||||
app.poutput('Card ATR: %s' % atr)
|
||||
|
||||
|
||||
def install(app):
|
||||
"""Route the pySim-shell equip/reset commands through the fast paths."""
|
||||
def _do_equip(statement):
|
||||
_tlog('do_equip_fast: start')
|
||||
do_equip_fast(app)
|
||||
_tlog('do_equip_fast: done')
|
||||
|
||||
def _do_reset(statement):
|
||||
_tlog('do_reset_fast: start')
|
||||
do_reset_fast(app)
|
||||
_tlog('do_reset_fast: done')
|
||||
|
||||
app.do_equip = _do_equip
|
||||
app.do_reset = _do_reset
|
||||
@@ -0,0 +1,197 @@
|
||||
# coding=utf-8
|
||||
"""GSMTAP-SIM UDP sender for live APDU capture.
|
||||
|
||||
Streams the card APDUs the server sends/receives as GSMTAP-SIM packets, so a
|
||||
GSMTAP receiver (SIMtrace Analyser ``--capture gsmtap``, Wireshark, or
|
||||
simtrace2-sniff) can follow the card dialogue live. Enabled with
|
||||
``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no UI
|
||||
or API for it.
|
||||
|
||||
The packet format is the one shared by libosmocore's ``gsmtap.h``,
|
||||
simtrace2-sniff, sigrok-iso7816-stream and the SIMtrace Analyser: a 16-byte
|
||||
big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type 0x00 =
|
||||
APDU, ``res`` flags) followed by the raw TPDU bytes.
|
||||
|
||||
The tracer emits **wire-shaped TPDUs**, not logical APDUs, because that is
|
||||
what a receiver pairs and decodes:
|
||||
|
||||
* case 4 (command with data + Le): the command without its Le byte plus the
|
||||
"61XX bytes available" status word, then the response data as a GET
|
||||
RESPONSE TPDU (``00C00000<len> + data + SW``);
|
||||
* case 2 / 1 / 3: one packet with the command, the response data and the SW;
|
||||
* unparseable APDUs fall back to a raw command packet and a raw response one.
|
||||
|
||||
The APDU data is always the real one - only the T=0 framing (which the PC/SC
|
||||
reader hides) is reconstructed, so the ``61XX`` length is the final response
|
||||
length and internal retries are not visible. No ATR/VCC/RST/PPS events are
|
||||
sent (the server has no line-level access).
|
||||
|
||||
Sending is fire-and-forget on a non-blocking socket: a missing listener must
|
||||
never affect card I/O.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from pySim.transport import ApduTracer
|
||||
from pySim.utils import h2b, parse_command_apdu
|
||||
|
||||
GSMTAP_VERSION = 0x02
|
||||
GSMTAP_HDR_LEN = 4 # in 32-bit words (16 bytes)
|
||||
GSMTAP_TYPE_SIM = 0x04
|
||||
|
||||
GSMTAP_SIM_APDU = 0x00
|
||||
GSMTAP_SIM_ATR = 0x01 # not sent; kept for reference
|
||||
|
||||
GSMTAP_UDP_PORT = 4729
|
||||
DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT
|
||||
|
||||
_HDR_FMT = '!BBBBHBBIBBBB' # 16 bytes, big-endian
|
||||
_HDR_SIZE = struct.calcsize(_HDR_FMT)
|
||||
|
||||
_GET_RESPONSE_CHUNK = 255 # max GET RESPONSE payload per TPDU
|
||||
|
||||
|
||||
def build_packet(sub_type, data, flags=0, slot_nr=0):
|
||||
"""Build a complete GSMTAP-SIM packet (header + payload) as bytes."""
|
||||
hdr = struct.pack(
|
||||
_HDR_FMT,
|
||||
GSMTAP_VERSION, # version
|
||||
GSMTAP_HDR_LEN, # hdr_len (in 32-bit words)
|
||||
GSMTAP_TYPE_SIM, # type
|
||||
0, # timeslot
|
||||
0, # arfcn
|
||||
0, # signal_dbm
|
||||
0, # snr_db
|
||||
0, # frame_number
|
||||
sub_type, # sub_type
|
||||
0, # antenna_nr
|
||||
slot_nr, # sub_slot
|
||||
flags, # res (GSMTAP_FLAG_*; 0 here)
|
||||
)
|
||||
return hdr + bytes(data)
|
||||
|
||||
|
||||
def parse_target(target):
|
||||
"""'HOST[:PORT]' -> (host, port); empty/None -> the default target."""
|
||||
text = str(target or '').strip()
|
||||
if not text:
|
||||
text = DEFAULT_TARGET
|
||||
if ':' in text:
|
||||
host, _sep, port = text.rpartition(':')
|
||||
return host or '127.0.0.1', int(port)
|
||||
return text, GSMTAP_UDP_PORT
|
||||
|
||||
|
||||
class GsmtapSender:
|
||||
"""Fire-and-forget GSMTAP-SIM UDP sender (never raises on send)."""
|
||||
|
||||
def __init__(self, host='127.0.0.1', port=GSMTAP_UDP_PORT):
|
||||
self._addr = (host, int(port))
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# A full socket buffer must never stall a card operation.
|
||||
self._sock.setblocking(False)
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
return '%s:%d' % self._addr
|
||||
|
||||
def send(self, sub_type, data, flags=0, slot_nr=0):
|
||||
try:
|
||||
self._sock.sendto(build_packet(sub_type, data, flags, slot_nr),
|
||||
self._addr)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def send_apdu(self, data, slot_nr=0):
|
||||
self.send(GSMTAP_SIM_APDU, data, slot_nr=slot_nr)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _apdu_case(cmd):
|
||||
"""ISO 7816-3 case of a command APDU, or None when unparseable."""
|
||||
try:
|
||||
case, _lc, _le, _data = parse_command_apdu(h2b(cmd))
|
||||
return case
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class GsmtapApduTracer(ApduTracer):
|
||||
"""pySim APDU tracer streaming wire-shaped TPDUs as GSMTAP-SIM packets.
|
||||
|
||||
See the module docstring for the emitted forms. Malformed hex never
|
||||
raises into pySim's transport.
|
||||
"""
|
||||
|
||||
def __init__(self, sender):
|
||||
super().__init__()
|
||||
self.sender = sender
|
||||
|
||||
def trace_command(self, cmd):
|
||||
"""Nothing is sent here: the wire packets need the response."""
|
||||
|
||||
def trace_response(self, cmd, sw, resp):
|
||||
try:
|
||||
raw = bytes.fromhex(cmd or '')
|
||||
resp_bytes = bytes.fromhex(resp or '')
|
||||
sw_bytes = bytes.fromhex(sw or '')
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
|
||||
case = _apdu_case(cmd)
|
||||
if case is None or not raw:
|
||||
# Unparseable APDU: keep the raw command / raw response form.
|
||||
if raw:
|
||||
self.sender.send_apdu(raw)
|
||||
if resp_bytes or sw_bytes:
|
||||
self.sender.send_apdu(resp_bytes + sw_bytes)
|
||||
return
|
||||
|
||||
if case == 4 and len(raw) > 1:
|
||||
# The wire TPDU carries no Le byte for case 4.
|
||||
body = raw[:-1]
|
||||
if not resp_bytes:
|
||||
self.sender.send_apdu(body + sw_bytes)
|
||||
return
|
||||
size = len(resp_bytes)
|
||||
# T=0: "61XX bytes available", then the data via GET RESPONSE.
|
||||
self.sender.send_apdu(body + bytes([0x61, min(size, 0xFF)]))
|
||||
offset = 0
|
||||
while offset < size:
|
||||
chunk = min(size - offset, _GET_RESPONSE_CHUNK)
|
||||
last = offset + chunk >= size
|
||||
self.sender.send_apdu(
|
||||
bytes([0x00, 0xC0, 0x00, 0x00, chunk])
|
||||
+ resp_bytes[offset:offset + chunk]
|
||||
+ (sw_bytes if last else b''))
|
||||
offset += chunk
|
||||
return
|
||||
|
||||
# case 1/2/3: the command TPDU and its response share one packet.
|
||||
self.sender.send_apdu(raw + resp_bytes + sw_bytes)
|
||||
|
||||
|
||||
class FanoutApduTracer(ApduTracer):
|
||||
"""Forward tracer callbacks to several tracers (e.g. stderr + GSMTAP)."""
|
||||
|
||||
def __init__(self, tracers):
|
||||
super().__init__()
|
||||
self.tracers = list(tracers)
|
||||
|
||||
def trace_command(self, cmd):
|
||||
for tracer in self.tracers:
|
||||
tracer.trace_command(cmd)
|
||||
|
||||
def trace_response(self, cmd, sw, resp):
|
||||
for tracer in self.tracers:
|
||||
tracer.trace_response(cmd, sw, resp)
|
||||
|
||||
def trace_reset(self):
|
||||
for tracer in self.tracers:
|
||||
tracer.trace_reset()
|
||||
@@ -0,0 +1,497 @@
|
||||
"""HTTP OTA (SCP81 / GP RAM over HTTP) emulation.
|
||||
|
||||
Phase A: terminal-side BIP emulation (OPEN/SEND/RECEIVE/CLOSE CHANNEL) plus a
|
||||
raw TCP capture listener. In the default redirect mode the card's BIP channel
|
||||
is always redirected to the locally configured target (the future PSK TLS
|
||||
platform) and the address the card requested is only logged; in passthru mode
|
||||
the channel dials the destination the card requests in OPEN CHANNEL (TCP,
|
||||
UICC in client mode, remote connection).
|
||||
|
||||
Reference behavior (TS 102 223 8.52-8.56, GP v2.2 Amendment B) is taken from
|
||||
the captured real-terminal traces in samples/HTTP_OTA/traces:
|
||||
OPEN CHANNEL TR: result, Channel status (38), Bearer description (35), Buffer size (39)
|
||||
SEND DATA TR: result, Channel data length (37)
|
||||
RECEIVE DATA TR: result, Channel data (36), Channel data length (37)
|
||||
CLOSE CHANNEL TR: result
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_LOG = 1000
|
||||
|
||||
|
||||
def ber_len_read(data, off):
|
||||
"""Read a BER-TLV length at data[off]; returns (length, next_offset)."""
|
||||
if off >= len(data):
|
||||
return 0, off
|
||||
b = data[off]
|
||||
if b < 0x80:
|
||||
return b, off + 1
|
||||
n = b & 0x7F
|
||||
if n == 0 or off + 1 + n > len(data):
|
||||
return 0, len(data)
|
||||
return int.from_bytes(data[off + 1:off + 1 + n], 'big'), off + 1 + n
|
||||
|
||||
|
||||
def proactive_tlvs(raw):
|
||||
"""Top-level TLV map {tag: value} of a D0 proactive command."""
|
||||
out = {}
|
||||
if not raw or raw[0] != 0xD0:
|
||||
return out
|
||||
ln, off = ber_len_read(raw, 1)
|
||||
end = min(len(raw), off + ln)
|
||||
while off + 1 < end:
|
||||
tag = raw[off]
|
||||
tlen, off2 = ber_len_read(raw, off + 1)
|
||||
val = raw[off2:off2 + tlen]
|
||||
off = off2 + tlen
|
||||
out.setdefault(tag, val)
|
||||
return out
|
||||
|
||||
|
||||
def parse_other_address(value):
|
||||
"""Decode an 'Other address' TLV (21=IPv4, 57=IPv6, F0=FQDN)."""
|
||||
if not value:
|
||||
return None
|
||||
t = value[0]
|
||||
if t == 0x21 and len(value) >= 5:
|
||||
return '.'.join(str(b) for b in value[1:5])
|
||||
if t == 0x57 and len(value) >= 17:
|
||||
return ':'.join('%x' % int.from_bytes(value[i:i + 2], 'big') for i in range(1, 17, 2))
|
||||
if t == 0xF0:
|
||||
return value[1:].decode('ascii', 'replace')
|
||||
return None
|
||||
|
||||
|
||||
def parse_transport_level(value):
|
||||
"""Decode an UICC/terminal interface transport level TLV -> (proto, port)."""
|
||||
if not value or len(value) < 3:
|
||||
return None, None
|
||||
return value[0], int.from_bytes(value[1:3], 'big')
|
||||
|
||||
|
||||
TAG_BEARER = 0x35
|
||||
TAG_CHANNEL_DATA = 0x36
|
||||
TAG_CHANNEL_DATA_LENGTH = 0x37
|
||||
TAG_CHANNEL_STATUS = 0x38
|
||||
TAG_BUFFER_SIZE = 0x39
|
||||
TAG_TRANSPORT_LEVEL = 0x3C
|
||||
TAG_OTHER_ADDRESS = 0x3E
|
||||
TAG_NAA = 0x47
|
||||
|
||||
|
||||
class BipChannel:
|
||||
def __init__(self, channel_id, sock, requested, target, buffer_size):
|
||||
self.id = channel_id
|
||||
self.sock = sock
|
||||
self.requested = requested
|
||||
self.target = target
|
||||
self.buffer_size = buffer_size or 512
|
||||
self.rx = bytearray()
|
||||
self.bytes_in = 0
|
||||
self.bytes_out = 0
|
||||
self.opened_at = time.time()
|
||||
self.peer_closed = False
|
||||
self.closed_reported = False
|
||||
self.notified_len = 0
|
||||
self.last_notify = 0.0
|
||||
|
||||
def pump(self, timeout=0.05):
|
||||
"""Move whatever the network has into the local buffer. Returns bytes moved."""
|
||||
if self.peer_closed:
|
||||
return 0
|
||||
moved = 0
|
||||
self.sock.settimeout(timeout)
|
||||
try:
|
||||
while True:
|
||||
chunk = self.sock.recv(self.buffer_size)
|
||||
if not chunk:
|
||||
self.peer_closed = True
|
||||
break
|
||||
self.rx.extend(chunk)
|
||||
self.bytes_in += len(chunk)
|
||||
moved += len(chunk)
|
||||
if len(chunk) < self.buffer_size:
|
||||
break
|
||||
except (socket.timeout, BlockingIOError):
|
||||
pass
|
||||
except OSError:
|
||||
self.peer_closed = True
|
||||
return moved
|
||||
|
||||
def send(self, data):
|
||||
self.sock.sendall(data)
|
||||
self.bytes_out += len(data)
|
||||
|
||||
def take(self, maxlen):
|
||||
self.pump()
|
||||
n = min(maxlen, len(self.rx), self.buffer_size)
|
||||
out = bytes(self.rx[:n])
|
||||
del self.rx[:n]
|
||||
return out
|
||||
|
||||
def available(self):
|
||||
self.pump()
|
||||
return len(self.rx)
|
||||
|
||||
def send_capacity(self):
|
||||
free = self.buffer_size - len(self.rx)
|
||||
return 0xFF if free > 0xFF else max(0, free)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class BipTerminal:
|
||||
"""Terminal (device) side of BIP: channels to the configured target."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = False
|
||||
self.mode = 'redirect'
|
||||
self.target = None
|
||||
self.channels = {}
|
||||
self.next_id = 1
|
||||
self.entries = []
|
||||
self.seq = 0
|
||||
self.lock = threading.Lock()
|
||||
self.pending_events = []
|
||||
self.on_data = None
|
||||
self._monitor = None
|
||||
|
||||
def log(self, kind, **fields):
|
||||
with self.lock:
|
||||
self.seq += 1
|
||||
entry = {'seq': self.seq, 't': time.time(), 'kind': kind}
|
||||
entry.update(fields)
|
||||
self.entries.append(entry)
|
||||
if len(self.entries) > MAX_LOG:
|
||||
del self.entries[:len(self.entries) - MAX_LOG]
|
||||
return entry
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Watch channels for incoming bytes and ask the card to fetch them.
|
||||
|
||||
The card only learns about server data through the Data available
|
||||
event (TS 102 223 7.5.10), so the socket must be pumped even while
|
||||
the card is idle."""
|
||||
while True:
|
||||
time.sleep(0.25)
|
||||
with self.lock:
|
||||
channels = list(self.channels.values())
|
||||
for ch in channels:
|
||||
try:
|
||||
ch.pump()
|
||||
except OSError:
|
||||
ch.peer_closed = True
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
# Report a dropped link (TS 102 223 7.5.11) only once the
|
||||
# buffered server data has been fetched: signalling the
|
||||
# drop while bytes are still waiting makes the card abort
|
||||
# the fetch and end the session prematurely.
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
if (self.on_data and ch.rx and not ch.peer_closed
|
||||
and (len(ch.rx) > ch.notified_len
|
||||
or time.time() - ch.last_notify > 2.0)):
|
||||
# Re-notify while data stays unfetched: the live card
|
||||
# sometimes needs the Data available event again to drain
|
||||
# a partially received TLS record.
|
||||
if self.on_data(ch):
|
||||
ch.notified_len = len(ch.rx)
|
||||
ch.last_notify = time.time()
|
||||
|
||||
def _start_monitor(self):
|
||||
if self._monitor is None or not self._monitor.is_alive():
|
||||
self._monitor = threading.Thread(target=self._monitor_loop,
|
||||
name='bip-monitor', daemon=True)
|
||||
self._monitor.start()
|
||||
|
||||
def enable(self, host=None, port=None, mode='redirect'):
|
||||
"""Enable the BIP terminal.
|
||||
|
||||
'redirect' (default) pins one target: every channel goes there whatever
|
||||
address the card requests. 'passthru' has no target at all: every
|
||||
channel dials the destination the card requested in OPEN CHANNEL."""
|
||||
self.mode = mode if mode in ('redirect', 'passthru') else 'redirect'
|
||||
self.target = (host, int(port)) if host and port not in (None, '') else None
|
||||
self.enabled = True
|
||||
self.log('enabled', mode=self.mode,
|
||||
target='%s:%d' % self.target if self.target else None)
|
||||
self._start_monitor()
|
||||
|
||||
def disable(self):
|
||||
self.enabled = False
|
||||
self.log('disabled')
|
||||
self.close_all(link_lost=True)
|
||||
self.target = None
|
||||
|
||||
def close_all(self, link_lost=False):
|
||||
for ch in list(self.channels.values()):
|
||||
self._close_channel(ch, link_lost=link_lost)
|
||||
|
||||
def _close_channel(self, ch, link_lost=False):
|
||||
ch.close()
|
||||
if self.channels.get(ch.id) is ch:
|
||||
del self.channels[ch.id]
|
||||
if link_lost:
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _queue_link_status(self, channel_id, status=None, info=0x05):
|
||||
"""Record a BIP link change that did not result from a proactive
|
||||
command (TS 102 223 7.5.11). The default is link not established +
|
||||
info 05 = link dropped; a successful background-mode OPEN CHANNEL
|
||||
reports link established instead. The server turns these into
|
||||
ENVELOPE (Channel status)."""
|
||||
with self.lock:
|
||||
if any(e['channel'] == channel_id for e in self.pending_events):
|
||||
return
|
||||
self.pending_events.append({
|
||||
'channel': channel_id,
|
||||
'status': channel_id & 0x07 if status is None else status,
|
||||
'info': info})
|
||||
|
||||
def take_pending_events(self):
|
||||
with self.lock:
|
||||
events, self.pending_events = self.pending_events, []
|
||||
return events
|
||||
|
||||
def _check_peer(self, ch):
|
||||
"""Notify once per channel when the peer closed the connection, after
|
||||
any buffered data has been fetched (see _monitor_loop)."""
|
||||
if ch.peer_closed and not ch.closed_reported and not ch.rx:
|
||||
ch.closed_reported = True
|
||||
self.log('peer-close', channel=ch.id)
|
||||
self._queue_link_status(ch.id)
|
||||
|
||||
def _alloc_id(self):
|
||||
for _ in range(7):
|
||||
cid = self.next_id
|
||||
self.next_id = 1 if cid >= 7 else cid + 1
|
||||
if cid not in self.channels:
|
||||
return cid
|
||||
return None
|
||||
|
||||
def open(self, requested_host, requested_port, buffer_size, proto=None):
|
||||
"""Open a channel.
|
||||
|
||||
Redirect modes connect to the pinned target; passthru dials the
|
||||
destination the card sent in OPEN CHANNEL (Other address + Transport
|
||||
level port). Returns (channel_id, error)."""
|
||||
if not self.enabled:
|
||||
return None, 'bip disabled'
|
||||
requested = '%s:%s' % (requested_host, requested_port)
|
||||
if self.mode == 'passthru':
|
||||
# Use the card's request as-is: TCP, UICC in client mode, remote
|
||||
# connection (TS 102 223 6.4.27.2 / 8.59). The specs define no
|
||||
# default port, so an incomplete or non-TCP request fails.
|
||||
host = (requested_host or '').strip()
|
||||
try:
|
||||
port = int(requested_port)
|
||||
except (TypeError, ValueError):
|
||||
port = 0
|
||||
if proto != 0x02:
|
||||
reason = 'card did not request TCP client transport (passthru)'
|
||||
elif not host or host == '-':
|
||||
reason = 'card did not request a destination address (passthru)'
|
||||
elif not 0 < port <= 0xFFFF:
|
||||
reason = 'card did not request a valid port (passthru)'
|
||||
else:
|
||||
reason = None
|
||||
if reason:
|
||||
self.log('open-fail', requested=requested, reason=reason)
|
||||
return None, reason
|
||||
target = (host, port)
|
||||
else:
|
||||
if not self.target:
|
||||
return None, 'bip disabled'
|
||||
target = self.target
|
||||
cid = self._alloc_id()
|
||||
if cid is None:
|
||||
self.log('open-fail', requested=requested, reason='no free channel')
|
||||
return None, 'no free channel'
|
||||
try:
|
||||
sock = socket.create_connection(target, timeout=2.0)
|
||||
except OSError as e:
|
||||
self.log('open-fail', requested=requested, target='%s:%d' % target, reason=str(e))
|
||||
return None, str(e)
|
||||
ch = BipChannel(cid, sock, requested, target, buffer_size)
|
||||
self.channels[cid] = ch
|
||||
self.log('open', channel=cid, requested=requested, target='%s:%d' % target,
|
||||
buffer_size=ch.buffer_size)
|
||||
return cid, None
|
||||
|
||||
def send(self, channel_id, data):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return False
|
||||
try:
|
||||
ch.send(data)
|
||||
except OSError as e:
|
||||
self.log('send-fail', channel=channel_id, error=str(e))
|
||||
self._close_channel(ch, link_lost=True)
|
||||
return False
|
||||
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
|
||||
return True
|
||||
|
||||
def receive(self, channel_id, maxlen):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return None
|
||||
data = ch.take(maxlen)
|
||||
if data:
|
||||
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
|
||||
hex=data.hex().upper()[:2000])
|
||||
# The TR announced the remainder via the channel-data-length TLV,
|
||||
# but the live card still waits for a fresh Data available event
|
||||
# before fetching it - re-arm the notification for what is left.
|
||||
ch.notified_len = 0
|
||||
self._check_peer(ch)
|
||||
return data
|
||||
|
||||
def available(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.available()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def send_capacity(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return 0
|
||||
n = ch.send_capacity()
|
||||
self._check_peer(ch)
|
||||
return n
|
||||
|
||||
def clear_log(self):
|
||||
with self.lock:
|
||||
self.entries = []
|
||||
|
||||
def close(self, channel_id):
|
||||
ch = self.channels.get(channel_id)
|
||||
if not ch:
|
||||
return False
|
||||
self.log('close', channel=channel_id, bytes_in=ch.bytes_in, bytes_out=ch.bytes_out)
|
||||
self._close_channel(ch)
|
||||
return True
|
||||
|
||||
def status(self):
|
||||
channels = []
|
||||
for ch in self.channels.values():
|
||||
channels.append({
|
||||
'id': ch.id,
|
||||
'requested': ch.requested,
|
||||
'target': '%s:%d' % ch.target,
|
||||
'buffer_size': ch.buffer_size,
|
||||
'bytes_in': ch.bytes_in,
|
||||
'bytes_out': ch.bytes_out,
|
||||
'pending': len(ch.rx),
|
||||
'peer_closed': ch.peer_closed,
|
||||
})
|
||||
return {
|
||||
'enabled': self.enabled,
|
||||
'mode': self.mode,
|
||||
'target': '%s:%d' % self.target if self.target else None,
|
||||
'channels': channels,
|
||||
'seq': self.seq,
|
||||
}
|
||||
|
||||
def entries_after(self, after=0):
|
||||
with self.lock:
|
||||
return [e for e in self.entries if e['seq'] > after]
|
||||
|
||||
|
||||
class TcpDumpServer:
|
||||
"""Plain TCP listener that logs whatever it receives (ClientHello capture).
|
||||
|
||||
Used as the BIP redirect target until the PSK TLS platform is brought up.
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, on_rx=None, on_log=None):
|
||||
self.on_rx = on_rx
|
||||
self.on_log = on_log
|
||||
self.stopped = False
|
||||
self.conns = []
|
||||
self.accepted = 0
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.bind((host, int(port)))
|
||||
self.sock.listen(4)
|
||||
self.host, self.port = self.sock.getsockname()[:2]
|
||||
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self.thread.start()
|
||||
if self.on_log:
|
||||
self.on_log('listener-start', host=self.host, port=self.port)
|
||||
|
||||
def _accept_loop(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
self.sock.settimeout(0.2)
|
||||
conn, addr = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.conns.append(conn)
|
||||
self.accepted += 1
|
||||
if self.on_log:
|
||||
self.on_log('conn', peer='%s:%d' % addr[:2])
|
||||
threading.Thread(target=self._conn_loop, args=(conn, addr), daemon=True).start()
|
||||
|
||||
def _conn_loop(self, conn, addr):
|
||||
total = 0
|
||||
try:
|
||||
while not self.stopped:
|
||||
conn.settimeout(0.2)
|
||||
try:
|
||||
data = conn.recv(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
total += len(data)
|
||||
if self.on_rx:
|
||||
self.on_rx('%s:%d' % addr[:2], data)
|
||||
finally:
|
||||
if self.on_log:
|
||||
self.on_log('conn-close', peer='%s:%d' % addr[:2], bytes=total)
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
if self.on_log:
|
||||
self.on_log('listener-stop', host=self.host, port=self.port)
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for conn in self.conns:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.conns = []
|
||||
# Wait for the accept loop to leave accept(): a thread blocked in
|
||||
# accept() keeps the listening socket alive for up to its poll
|
||||
# timeout, so the port must be released before stop() returns
|
||||
# (restarting a listener on the same port must not race).
|
||||
try:
|
||||
self.thread.join(timeout=2)
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,882 @@
|
||||
# coding=utf-8
|
||||
"""Network-condition simulation for the SIMple lab.
|
||||
|
||||
Replays the card-facing write patterns of a real phone when the network
|
||||
condition changes, following the trace study in ``projects/UICC_NAA.md``
|
||||
(section 13): EPS attach, service loss / limited service, roaming denial,
|
||||
2G fallback, SMS delivery, cell-broadcast reconfiguration and an
|
||||
AUTHENTICATE exchange.
|
||||
|
||||
Only the observed vocabulary is used: UPDATE BINARY (D6), UPDATE RECORD (DC),
|
||||
ENVELOPE (Event Download) and AUTHENTICATE. FPLMN and the 5GS location files
|
||||
are never written (no observed phone does - UICC_NAA.md section 12).
|
||||
|
||||
The ``build_*`` functions are pure and unit-tested (tests/test_netsim.py).
|
||||
``NetSimRunner`` applies a scenario to a live card through the helpers of
|
||||
``pysim_simple_server.server``; the caller holds the card lock.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Candidate paths per logical file. The first existing one is used, so a
|
||||
# USIM card is served from ADF.USIM and a GSM SIM from DF.GSM/DF.TELECOM.
|
||||
FILE_PATHS = {
|
||||
'imsi': ['ADF.USIM/6F07', 'DF.GSM/6F07'],
|
||||
'ehplmn': ['ADF.USIM/6FD9'],
|
||||
'spdi': ['ADF.USIM/6FCD'],
|
||||
'hplmnwact': ['ADF.USIM/6F62', 'DF.GSM/6F62'],
|
||||
'epsnsc': ['ADF.USIM/6FE4'],
|
||||
'loci': ['ADF.USIM/6F7E', 'DF.GSM/6F7E'],
|
||||
'psloci': ['ADF.USIM/6F73', 'DF.GSM/6F73'],
|
||||
'epsloci': ['ADF.USIM/6FE3'],
|
||||
'fplmn': ['ADF.USIM/6F7B', 'DF.GSM/6F7B'],
|
||||
'kc': ['ADF.USIM/4F20', 'DF.GSM/6F08'],
|
||||
'kcgprs': ['ADF.USIM/4F52', 'DF.GSM/6F09'],
|
||||
'smsstatus': ['ADF.USIM/6F43', 'DF.TELECOM/6F43', 'DF.GSM/6F43'],
|
||||
'cbmi': ['ADF.USIM/6F45', 'DF.GSM/6F45'],
|
||||
'cbmir': ['ADF.USIM/6F50', 'DF.GSM/6F50'],
|
||||
}
|
||||
|
||||
# Which simulated service state each scenario establishes (Network state
|
||||
# panel). Scenarios not listed (churn, cb_reconfig, authenticate) do not
|
||||
# touch the network registration and leave the previous state.
|
||||
SCENARIO_SERVICE = {
|
||||
'cold_boot': 'none',
|
||||
'attach_eps': 'normal',
|
||||
'attach_2g': 'normal',
|
||||
'service_lost': 'none',
|
||||
'limited_service': 'limited',
|
||||
'roaming_denied': 'limited',
|
||||
'sms_received': 'normal',
|
||||
}
|
||||
|
||||
# File status codes (LOCI/PSLOCI/EPSLOCI update status).
|
||||
ST_UPDATED = 0x00
|
||||
ST_NOT_UPDATED = 0x01
|
||||
ST_PLMN_NOT_ALLOWED = 0x02
|
||||
ST_LA_NOT_ALLOWED = 0x03
|
||||
|
||||
# Location status event (TS 102 223 section 8.27).
|
||||
LOC_STATUS_NORMAL = 0x00
|
||||
LOC_STATUS_LIMITED = 0x01
|
||||
LOC_STATUS_NO_SERVICE = 0x02
|
||||
|
||||
EVENT_LOCATION_STATUS = 0x03
|
||||
|
||||
|
||||
def _norm_hex(value, nbytes=None):
|
||||
"""Normalize a hex string, optionally checking the byte length."""
|
||||
h = re.sub(r'[^0-9a-fA-F]', '', value or '').upper()
|
||||
if nbytes is not None and len(h) != nbytes * 2:
|
||||
raise ValueError('expected %d hex bytes, got %r' % (nbytes, value))
|
||||
return h
|
||||
|
||||
|
||||
def _pad_ff(data, size):
|
||||
"""Pad (or truncate) a byte string to `size` with 0xFF."""
|
||||
if size is None or len(data) >= size:
|
||||
return data[:size] if size else data
|
||||
return data + b'\xFF' * (size - len(data))
|
||||
|
||||
|
||||
def rand_hex(nbytes, rng=None):
|
||||
r = rng or random
|
||||
return ''.join(r.choice('0123456789ABCDEF') for _ in range(nbytes * 2))
|
||||
|
||||
|
||||
def plmn_bcd(mcc, mnc):
|
||||
"""MCC/MNC digits -> 3-byte PLMN (TS 24.008 10.5.1.13).
|
||||
|
||||
Nibble order: byte0 = MCC2 MCC1, byte1 = MNC3 MCC3, byte2 = MNC2 MNC1
|
||||
(MNC3 = 'F' for two-digit MNCs). Cross-checked against the pySim
|
||||
PLMNsel test vector 228/06 -> 22 F8 60.
|
||||
"""
|
||||
mcc = re.sub(r'\D', '', str(mcc or ''))
|
||||
mnc = re.sub(r'\D', '', str(mnc or ''))
|
||||
if len(mcc) != 3:
|
||||
raise ValueError('MCC must have 3 digits, got %r' % mcc)
|
||||
if len(mnc) not in (2, 3):
|
||||
raise ValueError('MNC must have 2 or 3 digits, got %r' % mnc)
|
||||
m = [int(c) for c in mcc]
|
||||
n = [int(c) for c in mnc] + ([0xF] if len(mnc) == 2 else [])
|
||||
return '%02X%02X%02X' % ((m[1] << 4) | m[0], (n[2] << 4) | m[2], (n[1] << 4) | n[0])
|
||||
|
||||
|
||||
def _plmn_bytes(plmn_hex):
|
||||
return bytes.fromhex(_norm_hex(plmn_hex, 3))
|
||||
|
||||
|
||||
def parse_imsi(data_hex):
|
||||
"""EF.IMSI content -> IMSI digits, or None. Byte 1 is the length; the
|
||||
high nibble of byte 2 is the parity/identity nibble (TS 31.102 4.2.2)."""
|
||||
h = _norm_hex(data_hex)
|
||||
if len(h) < 4:
|
||||
return None
|
||||
body = h[2:]
|
||||
swapped = ''.join(body[i + 1] + body[i]
|
||||
for i in range(0, len(body) - 1, 2))
|
||||
digits = re.sub(r'F+$', '', swapped)
|
||||
return digits[1:] or None
|
||||
|
||||
|
||||
# ---- EPS NAS Security Context (EF.EPSNSC, TS 31.102 4.2.92) ----
|
||||
|
||||
|
||||
def build_epsnsc(ksi, kasme_hex, ul, dl, algo, size=54):
|
||||
"""One A0 TLV record, padded to the card's record size with FF."""
|
||||
kasme = bytes.fromhex(_norm_hex(kasme_hex, 32))
|
||||
inner = (bytes([0x80, 0x01, ksi & 0xFF])
|
||||
+ bytes([0x81, 0x20]) + kasme
|
||||
+ bytes([0x82, 0x04]) + (ul & 0xFFFFFFFF).to_bytes(4, 'big')
|
||||
+ bytes([0x83, 0x04]) + (dl & 0xFFFFFFFF).to_bytes(4, 'big')
|
||||
+ bytes([0x84, 0x01, algo & 0xFF]))
|
||||
rec = bytes([0xA0, len(inner)]) + inner
|
||||
return _pad_ff(rec, size).hex().upper()
|
||||
|
||||
|
||||
def build_epsnsc_invalidate(size=54, kasme_hex=None, algo=0x00):
|
||||
"""Invalid context: KSI 07, optional old KASME kept (UICC_NAA.md 5.2)."""
|
||||
kasme = (bytes.fromhex(_norm_hex(kasme_hex, 32)) if kasme_hex
|
||||
else b'\xFF' * 32)
|
||||
inner = (bytes([0x80, 0x01, 0x07])
|
||||
+ bytes([0x81, 0x20]) + kasme
|
||||
+ bytes([0x82, 0x04]) + b'\xFF' * 4
|
||||
+ bytes([0x83, 0x04]) + b'\xFF' * 4
|
||||
+ bytes([0x84, 0x01, algo & 0xFF]))
|
||||
rec = bytes([0xA0, len(inner)]) + inner
|
||||
return _pad_ff(rec, size).hex().upper()
|
||||
|
||||
|
||||
def parse_epsnsc_kasme(record_hex):
|
||||
"""Extract the 32-byte KASME from an EPSNSC record, or None."""
|
||||
try:
|
||||
data = bytes.fromhex(_norm_hex(record_hex))
|
||||
except ValueError:
|
||||
return None
|
||||
# The record is one A0 container; KASME is the 81 TLV inside it.
|
||||
if len(data) >= 2 and data[0] == 0xA0:
|
||||
data = data[2:2 + data[1]]
|
||||
i = 0
|
||||
while i + 2 <= len(data):
|
||||
tag, ln = data[i], data[i + 1]
|
||||
value = data[i + 2:i + 2 + ln]
|
||||
if tag == 0x81 and ln == 32:
|
||||
if all(b == 0xFF for b in value):
|
||||
return None # wiped key = invalid context
|
||||
return value.hex().upper()
|
||||
i += 2 + ln
|
||||
return None
|
||||
|
||||
|
||||
# ---- Location files (TS 31.102 4.2.16 / 4.2.23 / 4.2.91) ----
|
||||
|
||||
|
||||
def build_loci(tmsi_hex, plmn_hex, lac_hex, status=ST_UPDATED, rfu=0xFF):
|
||||
"""LOCI: TMSI(4) + LAI(5) + RFU(1) + update status(1) = 11 bytes."""
|
||||
return (bytes.fromhex(_norm_hex(tmsi_hex, 4)) + _plmn_bytes(plmn_hex)
|
||||
+ bytes.fromhex(_norm_hex(lac_hex, 2))
|
||||
+ bytes([rfu & 0xFF, status & 0xFF])).hex().upper()
|
||||
|
||||
|
||||
def build_loci_dummy(plmn_hex, status=ST_NOT_UPDATED):
|
||||
"""Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01 (010 = PLMN not
|
||||
allowed on a permanent rejection; UICC_NAA.md C3/C3a)."""
|
||||
return build_loci('FFFFFFFF', plmn_hex, 'FFFE', status)
|
||||
|
||||
|
||||
def build_psloci(ptmsi_hex, sig_hex, plmn_hex, lac_hex, rac_hex,
|
||||
status=ST_UPDATED):
|
||||
"""PSLOCI: P-TMSI(4) + signature(3) + RAI(6) + status(1) = 14 bytes."""
|
||||
return (bytes.fromhex(_norm_hex(ptmsi_hex, 4))
|
||||
+ bytes.fromhex(_norm_hex(sig_hex, 3)) + _plmn_bytes(plmn_hex)
|
||||
+ bytes.fromhex(_norm_hex(lac_hex, 2))
|
||||
+ bytes.fromhex(_norm_hex(rac_hex, 1))
|
||||
+ bytes([status & 0xFF])).hex().upper()
|
||||
|
||||
|
||||
def build_psloci_dummy(plmn_hex, status=ST_NOT_UPDATED):
|
||||
return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF', status)
|
||||
|
||||
|
||||
def build_epsloci(guti_hex, plmn_hex, tac_hex, status=ST_UPDATED):
|
||||
"""EPSLOCI: GUTI(12) + TAI(5) + EPS update status(1) = 18 bytes."""
|
||||
return (bytes.fromhex(_norm_hex(guti_hex, 12)) + _plmn_bytes(plmn_hex)
|
||||
+ bytes.fromhex(_norm_hex(tac_hex, 2))
|
||||
+ bytes([status & 0xFF])).hex().upper()
|
||||
|
||||
|
||||
def build_epsloci_dummy(status=ST_NOT_UPDATED, keep_plmn=None):
|
||||
"""EPSLOCI dummy: the EPS-mobile-identity pair `0B F6` (content length +
|
||||
GUTI type octet) is kept and the 12-byte GUTI is fully wiped; the TAC is
|
||||
`FF FE` and the last byte is the EPS update status (`01` = not updated on
|
||||
service loss, `02` = roaming not allowed on a permanent rejection - C3a,
|
||||
the spec model; the corpus never captured a 6FE3 rejection write).
|
||||
`keep_plmn` selects the NMR style that preserves the last visited TAI PLMN
|
||||
instead of wiping it - the guest style wipes GUTI *and* TAI PLMN
|
||||
(UICC_NAA.md 6.3/C3)."""
|
||||
plmn = _norm_hex(keep_plmn, 3) if keep_plmn else None
|
||||
tail = 'FF' * 10 + plmn if plmn else 'FF' * 13
|
||||
return '0BF6' + tail + 'FFFE' + '%02X' % (status & 0xFF)
|
||||
|
||||
|
||||
def fplmn_entries(data_hex):
|
||||
"""EF.FPLMN content as 3-byte PLMN entries; 'FFFFFF' marks an empty slot
|
||||
(TS 31.102 4.2.16: valid in any position, never a terminator)."""
|
||||
h = _norm_hex(data_hex)
|
||||
return [h[i:i + 6] for i in range(0, len(h) - 5, 6)]
|
||||
|
||||
|
||||
def insert_fplmn(data_hex, plmn_hex):
|
||||
"""Store a denied PLMN per TS 31.102 4.2.16: fill the first empty slot,
|
||||
otherwise shift the list left and append (the longest-held entry is
|
||||
lost). Returns the full updated EF content, or None when the PLMN is
|
||||
already listed (a duplicate entry is meaningless)."""
|
||||
plmn = _norm_hex(plmn_hex, 3)
|
||||
entries = fplmn_entries(data_hex)
|
||||
if plmn in entries:
|
||||
return None
|
||||
if not entries:
|
||||
return plmn
|
||||
try:
|
||||
idx = entries.index('FFFFFF')
|
||||
except ValueError:
|
||||
entries = entries[1:] + [plmn]
|
||||
else:
|
||||
entries[idx] = plmn
|
||||
return ''.join(entries)
|
||||
|
||||
|
||||
def remove_fplmn(data_hex, plmn_hex):
|
||||
"""Clear every occurrence of a PLMN from EF.FPLMN (a successful manual
|
||||
selection removes the entry, TS 23.122). Returns the full updated EF
|
||||
content, or None when the PLMN is not listed."""
|
||||
plmn = _norm_hex(plmn_hex, 3)
|
||||
entries = fplmn_entries(data_hex)
|
||||
if plmn not in entries:
|
||||
return None
|
||||
return ''.join('FFFFFF' if e == plmn else e for e in entries)
|
||||
|
||||
|
||||
# ---- Ciphering keys and CB/SMS files ----
|
||||
|
||||
|
||||
def build_kc(kc_hex, algo, size):
|
||||
"""Kc: 9-byte USIM form (Kc + algo) or the 33-byte GSM record form."""
|
||||
kc = bytes.fromhex(_norm_hex(kc_hex, 8))
|
||||
return _pad_ff(kc + bytes([algo & 0xFF]), size).hex().upper()
|
||||
|
||||
|
||||
def build_kc_invalidate(size):
|
||||
"""Invalid Kc: 1-byte 07, USIM 9-byte FF*8+07, GSM 33-byte 07+FF*32."""
|
||||
if size <= 1:
|
||||
return '07'
|
||||
if size == 9:
|
||||
return (b'\xFF' * 8 + b'\x07').hex().upper()
|
||||
return (b'\x07' + b'\xFF' * (size - 1)).hex().upper()
|
||||
|
||||
|
||||
def build_smsstatus(count):
|
||||
"""EF.SMSstatus: 2-byte big-endian counter (TS 51.011 10.5.9)."""
|
||||
return '%04X' % (count & 0xFFFF)
|
||||
|
||||
|
||||
def parse_smsstatus(data_hex):
|
||||
"""Current counter value, or None when unreadable/erased."""
|
||||
try:
|
||||
data = bytes.fromhex(_norm_hex(data_hex))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(data) < 2 or all(b == 0xFF for b in data):
|
||||
return None
|
||||
return int.from_bytes(data[:2], 'big')
|
||||
|
||||
|
||||
def bump_smsstatus(count):
|
||||
value = parse_smsstatus(count)
|
||||
return build_smsstatus((value + 1) if value is not None else 0x01FF)
|
||||
|
||||
|
||||
def build_cbmi(ids, size=None):
|
||||
"""CBMI: 2-byte message IDs; pad to the file size with FF when known."""
|
||||
out = b''.join((i & 0xFFFF).to_bytes(2, 'big') for i in ids)
|
||||
return _pad_ff(out, size).hex().upper()
|
||||
|
||||
|
||||
def build_cbmir(ranges, size=None):
|
||||
"""CBMIR: 4-byte ranges (low, high), most-significant byte first."""
|
||||
out = b''.join((lo & 0xFFFF).to_bytes(2, 'big')
|
||||
+ (hi & 0xFFFF).to_bytes(2, 'big') for lo, hi in ranges)
|
||||
return _pad_ff(out, size).hex().upper()
|
||||
|
||||
|
||||
# ---- Event Download and AUTHENTICATE ----
|
||||
|
||||
|
||||
def build_location_status_event(status, plmn_hex=None, lac_hex=None,
|
||||
cell_id_hex=None):
|
||||
"""Location status data object (TS 102 223 8.27), with the optional
|
||||
Location information object (8.19) only for normal service."""
|
||||
out = bytes([0x9B, 0x01, status & 0xFF])
|
||||
if (status == LOC_STATUS_NORMAL and plmn_hex and lac_hex
|
||||
and cell_id_hex):
|
||||
info = (_plmn_bytes(plmn_hex) + bytes.fromhex(_norm_hex(lac_hex, 2))
|
||||
+ bytes.fromhex(_norm_hex(cell_id_hex, 2)))
|
||||
out += bytes([0x13, len(info)]) + info
|
||||
return out.hex().upper()
|
||||
|
||||
|
||||
def build_auth_apdu(rand_hex, autn_hex, cla='00'):
|
||||
"""AUTHENTICATE (3G/EPS/5G, P2 81), TS 31.102 7.1.2.1.
|
||||
|
||||
Command data: L1 RAND L2 AUTN = 1+16+1+16 = 34 bytes (Lc 0x22).
|
||||
"""
|
||||
rand = bytes.fromhex(_norm_hex(rand_hex, 16))
|
||||
autn = bytes.fromhex(_norm_hex(autn_hex, 16))
|
||||
body = bytes([len(rand)]) + rand + bytes([len(autn)]) + autn
|
||||
return '%s880081%02X%s' % (cla.upper(), len(body), body.hex().upper())
|
||||
|
||||
|
||||
def build_auth_apdu_gsm(rand_hex, cla='a0'):
|
||||
"""2G AUTHENTICATE (GSM 11.11 8.8): P1/P2 00, Lc 0x10, RAND only.
|
||||
|
||||
The response is SRES (4 B) followed by Kc (8 B).
|
||||
"""
|
||||
rand = bytes.fromhex(_norm_hex(rand_hex, 16))
|
||||
return '%s88000010%s' % (cla.upper(), rand.hex().upper())
|
||||
|
||||
|
||||
def parse_auth_response(data_hex, gsm=False):
|
||||
"""Parse the AUTHENTICATE response: DB (success) or DC (sync failure) for
|
||||
3G/EPS/5G, SRES||Kc for the 2G SIM command."""
|
||||
try:
|
||||
data = bytes.fromhex(_norm_hex(data_hex or ''))
|
||||
except ValueError:
|
||||
return None
|
||||
if gsm:
|
||||
if len(data) < 12:
|
||||
return None
|
||||
return {'type': 'gsm', 'sres': data[0:4].hex().upper(),
|
||||
'kc': data[4:12].hex().upper()}
|
||||
if not data or data[0] not in (0xDB, 0xDC):
|
||||
return None
|
||||
out = {'type': 'success' if data[0] == 0xDB else 'synchronisation_failure'}
|
||||
i = 1
|
||||
while i + 2 <= len(data):
|
||||
ln = data[i]
|
||||
out.setdefault('objects', []).append(data[i + 1:i + 1 + ln].hex().upper())
|
||||
i += 1 + ln
|
||||
return out
|
||||
|
||||
|
||||
# ---- scenario runner ----
|
||||
|
||||
|
||||
class StepError(Exception):
|
||||
"""A write/select step failed; the scenario stops at that step."""
|
||||
|
||||
|
||||
def _default(value, rng=None):
|
||||
return value if value not in (None, '') else None
|
||||
|
||||
|
||||
def _hexint(value, default=0):
|
||||
"""Accept '0x1f'/1f/'31'/'3' style parameters as an integer."""
|
||||
if value in (None, ''):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
try:
|
||||
return int(text, 16) if re.fullmatch(r'(0[xX])?[0-9a-fA-F]+', text) else int(text)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
class NetSimRunner:
|
||||
"""Apply one scenario to a live card. The caller holds _CARD_LOCK."""
|
||||
|
||||
def __init__(self, srv, app, params=None, event_list=None, sleep=time.sleep):
|
||||
self.srv = srv
|
||||
self.app = app
|
||||
self.params = params or {}
|
||||
self.event_list = list(event_list or [])
|
||||
self.sleep = sleep
|
||||
self.lchan = app.rs.lchan[0]
|
||||
self.steps = []
|
||||
self._cleanups = []
|
||||
scc = getattr(getattr(srv, '_server_ref', None), 'scc', None)
|
||||
self.scc = scc
|
||||
|
||||
# -- parameter helpers
|
||||
|
||||
def p(self, key, default=None):
|
||||
v = self.params.get(key)
|
||||
return default if v in (None, '') else v
|
||||
|
||||
@property
|
||||
def mcc(self):
|
||||
return self.p('mcc', '001')
|
||||
|
||||
@property
|
||||
def mnc(self):
|
||||
return self.p('mnc', '01')
|
||||
|
||||
@property
|
||||
def plmn(self):
|
||||
return self.p('plmn') or plmn_bcd(self.mcc, self.mnc)
|
||||
|
||||
@property
|
||||
def lac(self):
|
||||
return self.p('lac') or rand_hex(2)
|
||||
|
||||
@property
|
||||
def cell_id(self):
|
||||
return self.p('cell_id') or rand_hex(2)
|
||||
|
||||
@property
|
||||
def tac(self):
|
||||
return self.p('tac') or self.lac
|
||||
|
||||
@property
|
||||
def rac(self):
|
||||
return self.p('rac') or rand_hex(1)
|
||||
|
||||
@property
|
||||
def kasme(self):
|
||||
return self.p('kasme') or rand_hex(32)
|
||||
|
||||
def status(self, default):
|
||||
v = self.p('location_status')
|
||||
return int(v, 16) if isinstance(v, str) else (int(v) if v is not None else default)
|
||||
|
||||
# -- step plumbing
|
||||
|
||||
def _add(self, action, **kw):
|
||||
step = {'action': action}
|
||||
step.update(kw)
|
||||
self.steps.append(step)
|
||||
return step
|
||||
|
||||
def _cleanup(self):
|
||||
for c in self._cleanups:
|
||||
try:
|
||||
c()
|
||||
except Exception:
|
||||
pass
|
||||
self._cleanups = []
|
||||
|
||||
def select(self, path):
|
||||
cur, cleanup = self.srv._select_path(self.lchan, path, self.app)
|
||||
if cleanup:
|
||||
self._cleanups.append(cleanup)
|
||||
return cur
|
||||
|
||||
def _open(self, key):
|
||||
last = None
|
||||
for path in FILE_PATHS[key]:
|
||||
try:
|
||||
self.select(path)
|
||||
return path
|
||||
except Exception as e:
|
||||
last = e
|
||||
raise StepError('no candidate file for %s (%s)' % (key, last))
|
||||
|
||||
def _meta(self):
|
||||
l = self.lchan
|
||||
return {'file_size': l.selected_file_size(),
|
||||
'record_len': l.selected_file_record_len(),
|
||||
'num_of_rec': l.selected_file_num_of_rec()}
|
||||
|
||||
def _check(self, data, sw, **kw):
|
||||
ok = sw == '9000'
|
||||
step = self._add(**kw, data=data, sw=sw, ok=ok)
|
||||
if not ok:
|
||||
raise StepError('%s returned SW %s' % (kw.get('action'), sw))
|
||||
return step
|
||||
|
||||
def write_binary(self, key, data_hex, pad=True, label=None, optional=False):
|
||||
try:
|
||||
path = self._open(key)
|
||||
except StepError as e:
|
||||
if optional:
|
||||
self._add('skip', file=label or key, note=str(e))
|
||||
return None
|
||||
raise
|
||||
size = self.lchan.selected_file_size()
|
||||
data = data_hex
|
||||
if pad and size:
|
||||
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
|
||||
_out, sw = self.lchan.update_binary(data)
|
||||
return self._check(data, sw, action='update_binary', file=label or key,
|
||||
key=key, path=path)
|
||||
|
||||
def write_record(self, key, data_hex, record=1, pad=True, label=None, optional=False):
|
||||
try:
|
||||
path = self._open(key)
|
||||
except StepError as e:
|
||||
if optional:
|
||||
self._add('skip', file=label or key, note=str(e))
|
||||
return None
|
||||
raise
|
||||
size = self.lchan.selected_file_record_len()
|
||||
data = data_hex
|
||||
if pad and size:
|
||||
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
|
||||
_out, sw = self.lchan.update_record(record, data)
|
||||
return self._check(data, sw, action='update_record', file=label or key,
|
||||
key=key, path=path, record=record)
|
||||
|
||||
def read_binary_current(self):
|
||||
data, sw = self.lchan.read_binary()
|
||||
return (data or ''), sw
|
||||
|
||||
# -- forbidden PLMNs (permanent #11 rejection, UICC_NAA.md C3a)
|
||||
|
||||
def home_plmns(self):
|
||||
"""3-byte HPLMN/EHPLMN entries from the cached Network-state monitor;
|
||||
TS 23.122: the home network is never stored in EF.FPLMN."""
|
||||
out = set()
|
||||
files = ((getattr(self.srv, 'net_state', None) or {}).get('files') or {})
|
||||
|
||||
def flat(key):
|
||||
f = files.get(key) or {}
|
||||
if f.get('present') and f.get('kind') == 'transparent':
|
||||
return _norm_hex(f.get('data'))
|
||||
return None
|
||||
|
||||
hp = flat('hplmnwact')
|
||||
if hp:
|
||||
# HPLMNwAcT records are 5 bytes (PLMN + access technology); the
|
||||
# first record is the HPLMN (TS 31.102 4.2.5).
|
||||
out.add(hp[0:6])
|
||||
ehp = flat('ehplmn')
|
||||
if ehp:
|
||||
out.update(fplmn_entries(ehp))
|
||||
if not out:
|
||||
# Fallback: the HPLMN is the IMSI's MCC/MNC. The IMSI does not
|
||||
# encode the MNC length, so both interpretations are guarded.
|
||||
imsi = parse_imsi(flat('imsi'))
|
||||
if imsi and len(imsi) >= 5 and imsi[:3].isdigit():
|
||||
try:
|
||||
out.add(plmn_bcd(imsi[0:3], imsi[3:5]))
|
||||
if len(imsi) >= 6 and imsi[5].isdigit():
|
||||
out.add(plmn_bcd(imsi[0:3], imsi[3:6]))
|
||||
except ValueError:
|
||||
pass
|
||||
return {h for h in out if h and h != 'FFFFFF'}
|
||||
|
||||
def write_fplmn(self, plmn_hex, optional=True):
|
||||
path = None
|
||||
try:
|
||||
path = self._open('fplmn')
|
||||
except StepError as e:
|
||||
if optional:
|
||||
self._add('skip', file='fplmn', note=str(e))
|
||||
return None
|
||||
raise
|
||||
plmn = _norm_hex(plmn_hex, 3)
|
||||
if plmn in self.home_plmns():
|
||||
self._add('skip', file='fplmn',
|
||||
note='home PLMN is never stored (TS 23.122)')
|
||||
return None
|
||||
size = self.lchan.selected_file_size()
|
||||
data, sw = self.read_binary_current()
|
||||
if sw != '9000' or not data:
|
||||
data = 'FF' * (size or 12)
|
||||
new_data = insert_fplmn(data, plmn)
|
||||
if new_data is None:
|
||||
self._add('skip', file='fplmn', note='PLMN already listed')
|
||||
return None
|
||||
return self.write_binary('fplmn', new_data, pad=False, label='fplmn')
|
||||
|
||||
def clear_fplmn(self, plmn_hex):
|
||||
"""A successful manual selection removes the PLMN from EF.FPLMN
|
||||
(TS 23.122); every occurrence is cleared and nothing is written when
|
||||
the PLMN is not listed."""
|
||||
try:
|
||||
self._open('fplmn')
|
||||
except StepError as e:
|
||||
self._add('skip', file='fplmn', note=str(e))
|
||||
return None
|
||||
data, sw = self.read_binary_current()
|
||||
if sw != '9000' or not data:
|
||||
self._add('skip', file='fplmn', note='read failed (SW %s)' % sw)
|
||||
return None
|
||||
new_data = remove_fplmn(data, plmn_hex)
|
||||
if new_data is None:
|
||||
return None
|
||||
return self.write_binary('fplmn', new_data, pad=False, label='fplmn')
|
||||
|
||||
def read_record_current(self, record=1):
|
||||
data, sw = self.lchan.read_record(record)
|
||||
return (data or ''), sw
|
||||
|
||||
def send_location_status(self, status):
|
||||
if EVENT_LOCATION_STATUS not in self.event_list:
|
||||
self._add('skip', file='location_status',
|
||||
note='event 0x03 not in SET UP EVENT LIST')
|
||||
return
|
||||
data = build_location_status_event(
|
||||
status,
|
||||
self.plmn if status == LOC_STATUS_NORMAL else None,
|
||||
self.lac if status == LOC_STATUS_NORMAL else None,
|
||||
self.cell_id if status == LOC_STATUS_NORMAL else None)
|
||||
if not self.scc:
|
||||
raise StepError('card session not available')
|
||||
_d, sw = self.srv._send_event_download(self.scc, EVENT_LOCATION_STATUS,
|
||||
bytes.fromhex(data))
|
||||
self._add('event', file='location_status', data=data, sw=sw,
|
||||
ok=(sw == '9000'))
|
||||
|
||||
# -- scenario building blocks
|
||||
|
||||
def invalidate_epsnsc(self, keep_key=True):
|
||||
try:
|
||||
self._open('epsnsc')
|
||||
except StepError as e:
|
||||
self._add('skip', file='epsnsc', note=str(e))
|
||||
return
|
||||
size = self.lchan.selected_file_record_len() or 54
|
||||
kasme = None
|
||||
if keep_key:
|
||||
data, sw = self.read_record_current(1)
|
||||
if sw == '9000':
|
||||
kasme = parse_epsnsc_kasme(data)
|
||||
self.write_record('epsnsc', build_epsnsc_invalidate(size, kasme),
|
||||
label='epsnsc')
|
||||
|
||||
def store_epsnsc(self):
|
||||
self.write_record('epsnsc', build_epsnsc(
|
||||
_hexint(self.p('ksi', '03'), 0x03),
|
||||
self.kasme,
|
||||
_hexint(self.p('ul', 0)), _hexint(self.p('dl', 0)),
|
||||
_hexint(self.p('algo', '02'), 0x02)),
|
||||
label='epsnsc', optional=True)
|
||||
|
||||
def write_real_locations(self, status=ST_UPDATED):
|
||||
# A successful attach is a manual selection of this PLMN: it is
|
||||
# removed from EF.FPLMN first (TS 23.122).
|
||||
self.clear_fplmn(self.plmn)
|
||||
self.write_binary('loci', build_loci(
|
||||
self.p('tmsi') or rand_hex(4), self.plmn, self.lac, status),
|
||||
label='loci', optional=True)
|
||||
self.write_binary('psloci', build_psloci(
|
||||
self.p('ptmsi') or rand_hex(4), self.p('ptmsi_sig') or rand_hex(3),
|
||||
self.plmn, self.lac, self.rac, status), label='psloci', optional=True)
|
||||
self.write_binary('epsloci', build_epsloci(
|
||||
self.p('guti') or rand_hex(12), self.plmn, self.tac, status),
|
||||
label='epsloci', optional=True)
|
||||
|
||||
def write_dummy_locations(self, status=ST_NOT_UPDATED, keep_plmn=None):
|
||||
"""Service loss: LOCI/PSLOCI keep the PLMN with the dummy status 01
|
||||
and EPSLOCI is wiped to `0B F6` + FF*13 + `FF FE 01` (UICC_NAA.md C3).
|
||||
A rejection status (010 = PLMN not allowed) is written to all three
|
||||
(EPSLOCI status 02 = roaming not allowed; C3a spec model).
|
||||
`keep_plmn` keeps the last visited TAI PLMN in the EPSLOCI dummy
|
||||
(NMR style) instead of wiping it."""
|
||||
self.write_binary('loci', build_loci_dummy(self.plmn, status),
|
||||
label='loci', optional=True)
|
||||
self.write_binary('psloci', build_psloci_dummy(self.plmn, status),
|
||||
label='psloci', optional=True)
|
||||
self.write_binary('epsloci',
|
||||
build_epsloci_dummy(status, keep_plmn=keep_plmn),
|
||||
label='epsloci', optional=True)
|
||||
|
||||
def invalidate_kc(self):
|
||||
for key in ('kc', 'kcgprs'):
|
||||
try:
|
||||
self._open(key)
|
||||
except StepError as e:
|
||||
self._add('skip', file=key, note=str(e))
|
||||
continue
|
||||
size = self.lchan.selected_file_size() or 9
|
||||
self.write_binary(key, build_kc_invalidate(size), pad=False,
|
||||
label=key)
|
||||
|
||||
def write_real_kc(self):
|
||||
kc = self.p('kc') or rand_hex(8)
|
||||
algo = int(self.p('kc_algo', 1))
|
||||
for key in ('kc', 'kcgprs'):
|
||||
try:
|
||||
self._open(key)
|
||||
except StepError as e:
|
||||
self._add('skip', file=key, note=str(e))
|
||||
continue
|
||||
size = self.lchan.selected_file_size() or 9
|
||||
self.write_binary(key, build_kc(kc, algo, size), pad=False,
|
||||
label=key)
|
||||
|
||||
def bump_sms_counter(self):
|
||||
try:
|
||||
self._open('smsstatus')
|
||||
except StepError as e:
|
||||
self._add('skip', file='smsstatus', note=str(e))
|
||||
return
|
||||
data, sw = self.read_binary_current()
|
||||
if sw != '9000':
|
||||
data = ''
|
||||
self.write_binary('smsstatus', bump_smsstatus(data), label='smsstatus')
|
||||
|
||||
# -- scenarios (UICC_NAA.md section 13)
|
||||
|
||||
def sc_cold_boot(self):
|
||||
self.invalidate_epsnsc(keep_key=False)
|
||||
if self.p('dummy_locations', True):
|
||||
self.write_dummy_locations()
|
||||
|
||||
def sc_attach_eps(self):
|
||||
self.store_epsnsc()
|
||||
self.write_real_locations(ST_UPDATED)
|
||||
|
||||
def sc_service_lost(self):
|
||||
if self.p('send_event', True):
|
||||
self.send_location_status(self.status(LOC_STATUS_NO_SERVICE))
|
||||
if self.p('invalidate_epsnsc', True):
|
||||
self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True)))
|
||||
if self.p('dummy_locations', True):
|
||||
self.write_dummy_locations()
|
||||
if self.p('write_kc', True):
|
||||
self.invalidate_kc()
|
||||
|
||||
def sc_limited_service(self):
|
||||
if self.p('send_event', True):
|
||||
self.send_location_status(self.status(LOC_STATUS_LIMITED))
|
||||
if self.p('invalidate_epsnsc', True):
|
||||
self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True)))
|
||||
if self.p('dummy_locations', True):
|
||||
self.write_dummy_locations()
|
||||
|
||||
def sc_roaming_denied(self):
|
||||
# Permanent rejection (NAS cause #11): the Location status event is
|
||||
# indistinguishable from limited service (TS 102 223 8.27) - the
|
||||
# difference is the 010 status bytes and the EF.FPLMN entry
|
||||
# (UICC_NAA.md C3a).
|
||||
if self.p('send_event', True):
|
||||
self.send_location_status(self.status(LOC_STATUS_LIMITED))
|
||||
if self.p('invalidate_epsnsc', True):
|
||||
self.invalidate_epsnsc(keep_key=False)
|
||||
if self.p('dummy_locations', True):
|
||||
self.write_dummy_locations(status=self.status(ST_PLMN_NOT_ALLOWED))
|
||||
if self.p('write_fplmn', True):
|
||||
self.write_fplmn(self.plmn)
|
||||
|
||||
def sc_churn(self):
|
||||
count = int(self.p('churn_count', 3))
|
||||
delay = int(self.p('churn_delay_ms', 150)) / 1000.0
|
||||
real = build_epsnsc(
|
||||
_hexint(self.p('ksi', '01'), 0x01),
|
||||
self.kasme,
|
||||
_hexint(self.p('ul', 0)), _hexint(self.p('dl', 0)),
|
||||
_hexint(self.p('algo', '02'), 0x02))
|
||||
try:
|
||||
self._open('epsnsc')
|
||||
except StepError as e:
|
||||
self._add('skip', file='epsnsc', note=str(e))
|
||||
return
|
||||
size = self.lchan.selected_file_record_len() or 54
|
||||
invalid = build_epsnsc_invalidate(size, self.kasme)
|
||||
for _i in range(max(1, count)):
|
||||
self.write_record('epsnsc', real, label='epsnsc')
|
||||
if delay:
|
||||
self.sleep(delay)
|
||||
self.write_record('epsnsc', invalid, label='epsnsc')
|
||||
if delay:
|
||||
self.sleep(delay)
|
||||
|
||||
def sc_attach_2g(self):
|
||||
self.write_real_kc()
|
||||
self.write_real_locations(ST_UPDATED)
|
||||
|
||||
def sc_sms_received(self):
|
||||
self.bump_sms_counter()
|
||||
if self.p('sms_location', True):
|
||||
self.write_real_locations(ST_UPDATED)
|
||||
|
||||
def sc_cb_reconfig(self):
|
||||
clear = bool(self.p('cb_clear', False))
|
||||
try:
|
||||
self._open('cbmi')
|
||||
except StepError as e:
|
||||
self._add('skip', file='cbmi', note=str(e))
|
||||
clear = None
|
||||
if clear is not None:
|
||||
size = self.lchan.selected_file_size()
|
||||
if clear:
|
||||
self.write_binary('cbmi', 'FF' * (size or 20), pad=False,
|
||||
label='cbmi')
|
||||
else:
|
||||
ids = self.p('cbmi_ids') or [0x111F, 0x1112]
|
||||
self.write_binary('cbmi', build_cbmi(ids, size), pad=False,
|
||||
label='cbmi')
|
||||
try:
|
||||
self._open('cbmir')
|
||||
except StepError:
|
||||
return
|
||||
size = self.lchan.selected_file_size()
|
||||
if clear:
|
||||
self.write_binary('cbmir', 'FF' * (size or 40), pad=False,
|
||||
label='cbmir')
|
||||
else:
|
||||
ranges = self.p('cbmir_ranges') or [[0x111F, 0x111F],
|
||||
[0x1112, 0x1112]]
|
||||
self.write_binary('cbmir', build_cbmir(ranges, size), pad=False,
|
||||
label='cbmir')
|
||||
|
||||
def sc_authenticate(self):
|
||||
rand = self.p('rand') or rand_hex(16)
|
||||
autn = self.p('autn') or rand_hex(16)
|
||||
if not self.scc:
|
||||
raise StepError('card session not available')
|
||||
# The command follows the card's class: a UICC gets the 3G/EPS/5G
|
||||
# AUTHENTICATE (P2 81, RAND + AUTN), a SIM the 2G one (P1/P2 00,
|
||||
# RAND only, SRES + Kc in the response).
|
||||
cla = (getattr(self.scc, 'cla_byte', None) or '00').upper()
|
||||
gsm = cla == 'A0'
|
||||
apdu = (build_auth_apdu_gsm(rand, cla) if gsm
|
||||
else build_auth_apdu(rand, autn, cla))
|
||||
data, sw = self.scc._tp.send_apdu(apdu)
|
||||
if sw.startswith('61'):
|
||||
data, sw = self.scc._tp.send_apdu(cla + 'C00000' + sw[2:4])
|
||||
parsed = parse_auth_response(data, gsm=gsm)
|
||||
self._add('authenticate', data=apdu, response=(data or '').upper(),
|
||||
parsed=parsed, sw=sw, ok=True)
|
||||
|
||||
SCENARIOS = {
|
||||
'cold_boot': sc_cold_boot,
|
||||
'attach_eps': sc_attach_eps,
|
||||
'service_lost': sc_service_lost,
|
||||
'limited_service': sc_limited_service,
|
||||
'roaming_denied': sc_roaming_denied,
|
||||
'churn': sc_churn,
|
||||
'attach_2g': sc_attach_2g,
|
||||
'sms_received': sc_sms_received,
|
||||
'cb_reconfig': sc_cb_reconfig,
|
||||
'authenticate': sc_authenticate,
|
||||
}
|
||||
|
||||
def run(self, scenario):
|
||||
fn = self.SCENARIOS.get(scenario)
|
||||
if not fn:
|
||||
raise ValueError('unknown scenario %r' % scenario)
|
||||
error = None
|
||||
try:
|
||||
fn(self)
|
||||
except StepError as e:
|
||||
error = str(e)
|
||||
except Exception as e:
|
||||
error = '%s: %s' % (type(e).__name__, e)
|
||||
finally:
|
||||
self._cleanup()
|
||||
return {'scenario': scenario, 'success': error is None, 'error': error,
|
||||
'steps': self.steps}
|
||||
|
||||
|
||||
def run_scenario(srv, app, scenario, params=None, event_list=None,
|
||||
sleep=time.sleep):
|
||||
"""Run `scenario` against the equipped card; returns the step log."""
|
||||
runner = NetSimRunner(srv, app, params=params, event_list=event_list,
|
||||
sleep=sleep)
|
||||
return runner.run(scenario)
|
||||
@@ -0,0 +1,307 @@
|
||||
# coding=utf-8
|
||||
"""Network-state monitor for the SIMple lab.
|
||||
|
||||
Keeps a cached, per-card-session view of the network-related EFs: the files
|
||||
the network simulator writes plus IMSI / EHPLMN / SPDI / HPLMNwAcT / FPLMN
|
||||
(trace study: ``projects/UICC_NAA.md``). It also tracks the *simulated*
|
||||
service state (normal / limited / no service, ``Undefined`` until something
|
||||
is simulated) and derives the current location + roaming view for the Phone
|
||||
tab's "Network state" panel.
|
||||
|
||||
This module is pure: the server owns all card I/O (``_netstate_read`` in
|
||||
``server.py``) and passes freshly read file entries in. ``mcc_mnc_data`` is
|
||||
the optional MCC/MNC operator list the server loads (``--mcc-mnc-list``).
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from pysim_simple_server import netsim
|
||||
|
||||
SERVICE_NORMAL = 'normal'
|
||||
SERVICE_LIMITED = 'limited'
|
||||
SERVICE_NONE = 'none'
|
||||
|
||||
|
||||
def _def(key, name, fid):
|
||||
return {'key': key, 'name': name, 'fid': fid,
|
||||
'paths': list(netsim.FILE_PATHS.get(key) or [])}
|
||||
|
||||
|
||||
# Display order of the monitor panel.
|
||||
FILE_DEFS = [
|
||||
_def('imsi', 'EF.IMSI', '6F07'),
|
||||
_def('ehplmn', 'EF.EHPLMN', '6FD9'),
|
||||
_def('spdi', 'EF.SPDI', '6FCD'),
|
||||
_def('hplmnwact', 'EF.HPLMNwAcT', '6F62'),
|
||||
_def('loci', 'EF.LOCI', '6F7E'),
|
||||
_def('psloci', 'EF.PSLOCI', '6F73'),
|
||||
_def('epsloci', 'EF.EPSLOCI', '6FE3'),
|
||||
_def('epsnsc', 'EF.EPSNSC', '6FE4'),
|
||||
_def('cbmi', 'EF.CBMI', '6F45'),
|
||||
_def('cbmir', 'EF.CBMIR', '6F50'),
|
||||
_def('smsstatus', 'EF.SMSS', '6F43'),
|
||||
_def('fplmn', 'EF.FPLMN', '6F7B'),
|
||||
]
|
||||
MONITORED_KEYS = [d['key'] for d in FILE_DEFS]
|
||||
FILE_BY_KEY = {d['key']: d for d in FILE_DEFS}
|
||||
|
||||
|
||||
def _norm(value):
|
||||
return re.sub(r'[^0-9a-fA-F]', '', value or '').upper()
|
||||
|
||||
|
||||
def new_state():
|
||||
"""Empty monitor state (a new card session)."""
|
||||
return {'files': {},
|
||||
'service': {'state': None, 'source': None, 'time': None},
|
||||
'network': None, 'read_at': None}
|
||||
|
||||
|
||||
def set_service(state, service, source=None):
|
||||
"""Record the simulated service state ('normal'/'limited'/'none')."""
|
||||
if not state:
|
||||
return
|
||||
state['service'] = {'state': service, 'source': source, 'time': time.time()}
|
||||
|
||||
|
||||
def set_read_time(state):
|
||||
if state:
|
||||
state['read_at'] = time.time()
|
||||
|
||||
|
||||
def patch_file(state, key, path, data, source='write', record=None):
|
||||
"""Patch one cached file from bytes we just wrote (no re-read needed)."""
|
||||
if not state or key not in FILE_BY_KEY:
|
||||
return
|
||||
d = FILE_BY_KEY[key]
|
||||
cur = (state.get('files') or {}).get(key) or {}
|
||||
if record is not None or cur.get('kind') == 'record':
|
||||
records = {r.get('num'): r.get('data', '')
|
||||
for r in (cur.get('records') or [])}
|
||||
records[int(record or 1)] = _norm(data)
|
||||
body = {'kind': 'record',
|
||||
'records': [{'num': n, 'data': records[n]}
|
||||
for n in sorted(records)]}
|
||||
else:
|
||||
body = {'kind': 'transparent', 'data': _norm(data)}
|
||||
entry = {'name': d['name'], 'fid': d['fid'],
|
||||
'path': path or cur.get('path') or (d['paths'][0] if d['paths'] else None),
|
||||
'present': True, 'source': source, 'updated': time.time()}
|
||||
entry.update(body)
|
||||
state.setdefault('files', {})[key] = entry
|
||||
|
||||
|
||||
def apply_steps(state, steps):
|
||||
"""Patch the cached files from a net-sim step log (writes we performed)."""
|
||||
if not state:
|
||||
return
|
||||
for s in steps or []:
|
||||
act = s.get('action')
|
||||
key = s.get('key')
|
||||
if act in ('update_binary', 'update_record') and key in FILE_BY_KEY:
|
||||
patch_file(state, key, s.get('path'), s.get('data') or '',
|
||||
source='write',
|
||||
record=s.get('record') if act == 'update_record' else None)
|
||||
|
||||
|
||||
def merge_read(state, entries, source='read'):
|
||||
"""Merge freshly read file entries (server I/O) into the state."""
|
||||
if not state or not entries:
|
||||
return
|
||||
for key, entry in entries.items():
|
||||
if not entry:
|
||||
continue
|
||||
entry = dict(entry)
|
||||
entry['source'] = source
|
||||
state.setdefault('files', {})[key] = entry
|
||||
|
||||
|
||||
# ---- decoding helpers (mirror the client-side EF decoders) ----
|
||||
|
||||
def parse_imsi(data_hex):
|
||||
"""EF.IMSI digits or None (TS 31.102 4.2.2)."""
|
||||
return netsim.parse_imsi(data_hex)
|
||||
|
||||
|
||||
def plmn_from_hex(hex3):
|
||||
"""3-byte PLMN (TS 24.008 BCD) -> {'mcc','mnc','plmn'} or None."""
|
||||
h = _norm(hex3)
|
||||
if len(h) != 6 or h == 'FFFFFF':
|
||||
return None
|
||||
b = [int(h[i:i + 2], 16) for i in (0, 2, 4)]
|
||||
d = lambda v: v & 0x0F
|
||||
e = lambda v: (v >> 4) & 0x0F
|
||||
if d(b[0]) > 9 or e(b[0]) > 9 or d(b[1]) > 9:
|
||||
return None
|
||||
if d(b[2]) > 9 or e(b[2]) > 9:
|
||||
return None
|
||||
mcc = '%d%d%d' % (d(b[0]), e(b[0]), d(b[1]))
|
||||
mnc = '%d%d' % (d(b[2]), e(b[2]))
|
||||
mnc3 = e(b[1])
|
||||
if mnc3 != 0x0F:
|
||||
if mnc3 > 9:
|
||||
return None
|
||||
mnc += '%d' % mnc3
|
||||
return {'mcc': mcc, 'mnc': mnc, 'plmn': mcc + mnc}
|
||||
|
||||
|
||||
def _plmn_hex_list(data_hex, rec=3):
|
||||
"""Valid 3-byte PLMN entries of a transparent list file ('FFFFFF' gaps
|
||||
are skipped, never treated as terminators)."""
|
||||
h = _norm(data_hex)
|
||||
step = rec * 2
|
||||
return [h[i:i + 6] for i in range(0, len(h) - 5, step)
|
||||
if plmn_from_hex(h[i:i + 6])]
|
||||
|
||||
|
||||
def plmn_list(data_hex, rec=3):
|
||||
return [plmn_from_hex(h) for h in _plmn_hex_list(data_hex, rec)]
|
||||
|
||||
|
||||
def _transparent(files, key):
|
||||
f = (files or {}).get(key) or {}
|
||||
if f.get('present') and f.get('kind') == 'transparent':
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def _current_location(files):
|
||||
"""Current PLMN + area, EPSLOCI (TAI) -> PSLOCI (RAI) -> LOCI (LAI).
|
||||
|
||||
Dummies keep the PLMN in LOCI/PSLOCI while EPSLOCI is wiped, so the
|
||||
fallback order keeps the location known after service loss."""
|
||||
for key, area, off in (('epsloci', 'TAI', 12), ('psloci', 'RAI', 7),
|
||||
('loci', 'LAI', 4)):
|
||||
f = _transparent(files, key)
|
||||
if not f:
|
||||
continue
|
||||
h = _norm(f.get('data'))
|
||||
if len(h) < (off + 5) * 2:
|
||||
continue
|
||||
p = plmn_from_hex(h[off * 2:off * 2 + 6])
|
||||
if not p:
|
||||
continue
|
||||
return {'plmn': p['plmn'], 'mcc': p['mcc'], 'mnc': p['mnc'],
|
||||
'hex': h[off * 2:off * 2 + 6], 'area': area,
|
||||
'lac': h[off * 2 + 6:off * 2 + 10], 'source': f.get('source')}
|
||||
return None
|
||||
|
||||
|
||||
def _home_sets(files):
|
||||
"""(home PLMN hex, equivalent-home PLMN hex) sets from HPLMNwAcT, EHPLMN
|
||||
and the IMSI (fallback), as 3-byte uppercase hex."""
|
||||
home, eq = set(), set()
|
||||
f = _transparent(files, 'hplmnwact')
|
||||
if f:
|
||||
# First 5-byte record is the HPLMN (TS 31.102 4.2.5).
|
||||
head = _norm(f.get('data'))[0:6]
|
||||
if plmn_from_hex(head):
|
||||
home.add(head)
|
||||
f = _transparent(files, 'ehplmn')
|
||||
if f:
|
||||
eq.update(_plmn_hex_list(f.get('data')))
|
||||
if not home:
|
||||
imsi = parse_imsi((_transparent(files, 'imsi') or {}).get('data'))
|
||||
if imsi and len(imsi) >= 5 and imsi[:3].isdigit():
|
||||
try:
|
||||
home.add(netsim.plmn_bcd(imsi[0:3], imsi[3:5]))
|
||||
if len(imsi) >= 6 and imsi[5].isdigit():
|
||||
home.add(netsim.plmn_bcd(imsi[0:3], imsi[3:6]))
|
||||
except ValueError:
|
||||
pass
|
||||
return home, eq
|
||||
|
||||
|
||||
def home_plmn(files):
|
||||
"""The simulator's "home network" PLMN: the first EF.HPLMNwAcT record
|
||||
(the HPLMN per TS 31.102 4.2.5), falling back to the IMSI (the MNC is
|
||||
taken as 2 digits — the IMSI does not encode its length)."""
|
||||
f = _transparent(files, 'hplmnwact')
|
||||
if f:
|
||||
p = plmn_from_hex(_norm(f.get('data'))[0:6])
|
||||
if p:
|
||||
return {'mcc': p['mcc'], 'mnc': p['mnc'], 'plmn': p['plmn'],
|
||||
'source': 'hplmnwact'}
|
||||
imsi = parse_imsi((_transparent(files, 'imsi') or {}).get('data'))
|
||||
if imsi and len(imsi) >= 5 and imsi[:5].isdigit():
|
||||
return {'mcc': imsi[:3], 'mnc': imsi[3:5], 'plmn': imsi[:5],
|
||||
'source': 'imsi'}
|
||||
return None
|
||||
|
||||
|
||||
def _rejected(files):
|
||||
"""A permanent 'PLMN not allowed' rejection fingerprint: status 010 in a
|
||||
location file (UICC_NAA.md C3a) or a non-empty EF.FPLMN."""
|
||||
off = {'loci': 10, 'psloci': 13, 'epsloci': 17}
|
||||
for key, idx in off.items():
|
||||
f = _transparent(files, key)
|
||||
if not f:
|
||||
continue
|
||||
h = _norm(f.get('data'))
|
||||
if len(h) >= (idx + 1) * 2 and h[idx * 2:idx * 2 + 2] == '02':
|
||||
return True
|
||||
f = _transparent(files, 'fplmn')
|
||||
if f and _plmn_hex_list(f.get('data')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _operator(mcc_mnc_data, mcc, mnc):
|
||||
"""Exact MCC/MNC lookup in the optional operator list; a country-only
|
||||
fallback (first entry of that MCC) keeps the location line useful."""
|
||||
if not mcc_mnc_data:
|
||||
return None
|
||||
try:
|
||||
mcc_i, mnc_i = int(mcc), int(mnc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
fallback = None
|
||||
for e in mcc_mnc_data:
|
||||
try:
|
||||
if int(str(e.get('mcc') or '').strip() or -1) != mcc_i:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
if fallback is None:
|
||||
fallback = e
|
||||
try:
|
||||
if int(str(e.get('mnc') or '').strip() or -1) == mnc_i:
|
||||
return {'country': e.get('countryName'),
|
||||
'operator': e.get('brand') or e.get('operator')}
|
||||
except ValueError:
|
||||
continue
|
||||
if fallback:
|
||||
return {'country': fallback.get('countryName'), 'operator': None}
|
||||
return None
|
||||
|
||||
|
||||
def compute_network(state, mcc_mnc_data=None):
|
||||
"""Derive the monitor header data from the cached state."""
|
||||
state = state or {}
|
||||
files = state.get('files') or {}
|
||||
loc = _current_location(files)
|
||||
home, eq = _home_sets(files)
|
||||
roaming = None
|
||||
if loc and home:
|
||||
if loc['hex'] in home:
|
||||
roaming = 'home'
|
||||
elif loc['hex'] in eq:
|
||||
roaming = 'equivalent'
|
||||
else:
|
||||
roaming = 'guest'
|
||||
location = None
|
||||
if loc:
|
||||
op = _operator(mcc_mnc_data, loc['mcc'], loc['mnc'])
|
||||
location = {'plmn': loc['plmn'], 'mcc': loc['mcc'], 'mnc': loc['mnc'],
|
||||
'area': loc['area'], 'lac': loc['lac'],
|
||||
'country': (op or {}).get('country'),
|
||||
'operator': (op or {}).get('operator'),
|
||||
'roaming': roaming, 'rejected': _rejected(files),
|
||||
'source': loc.get('source')}
|
||||
network = {'service': state.get('service') or
|
||||
{'state': None, 'source': None, 'time': None},
|
||||
'location': location,
|
||||
'home': home_plmn(files)}
|
||||
state['network'] = network
|
||||
return network
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Import card presets from a card vendor's **outfile** (v3.27.0).
|
||||
|
||||
An outfile is the vendor personalization tool's plain-text dump: a long
|
||||
banner/configuration header (customer, batch, profile, ...) followed by the
|
||||
actual data. The data starts at a **column-name line** - whitespace-separated
|
||||
tokens that include ``ICCID`` (the first token may carry a ``var_out:``-style
|
||||
prefix) - and continues with one whitespace-separated row per card. The
|
||||
header and every column the presets do not use are ignored.
|
||||
|
||||
Recognised columns (case-insensitive): ``ICCID``, ``PIN1``/``PIN2``,
|
||||
``PUK1``/``PUK2``, ``ADM`` (the key's hex value - 4-16 hex digits in **byte
|
||||
pairs**, the rule ``/api/verify-adm`` applies; this is the form the preset store
|
||||
uses, where pySim pads it with 'f' to the 8 CHV bytes), ``PSK`` (the SCP81 key, 16 bytes; an
|
||||
optional ``PSKID``/``PSK_ID`` column names its identity, defaulting to the
|
||||
card's ICCID) and ``KICn``/``KIDn`` - one keyset per numbered pair, up to 15.
|
||||
Everything else (``IMSI*``, ``EKI``, ``KIKn`` - the key-management key, no
|
||||
use yet) is skipped.
|
||||
|
||||
The **KIc/KID bytes are never in the file**: the keyset number is the column
|
||||
number (``KIC2`` -> keyset 2) and the algorithm nibble follows the key size -
|
||||
8 bytes DES (1), 16 bytes 3DES2 (5) or AES (2, the caller's choice), 24 bytes
|
||||
3DES3 (9); any other size skips that keyset with a report line. The KIc and
|
||||
KID bytes carry the same number (TS 102 225 A.2).
|
||||
|
||||
The **TARs** never come from the file either: the caller passes the TAR table
|
||||
(the three mandatory role entries with their spec defaults by default) and one
|
||||
default keyset applied to every entry of every imported card; without one the
|
||||
card's first keyset is used (a multi-keyset card would otherwise fail the
|
||||
store's "a non-zero MSL needs a default keyset" rule).
|
||||
|
||||
``parse_outfile()`` is pure: it returns the preset dicts (the store's shape)
|
||||
and a report. Nothing is validated here - the preset store's normalisation
|
||||
and validation own that, and an entry it refuses is reported by the import.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from pysim_simple_server import presets
|
||||
|
||||
# The algorithm nibble of KIc/KID by key size (TS 102 225 A.2; the project's
|
||||
# _CRYPT_ALGO): 8 bytes = DES, 16 = 3DES2 or AES (the caller picks), 24 =
|
||||
# 3DES3. Anything else is refused with a report line.
|
||||
_KEY_ALGO = {8: 0x01, 24: 0x09}
|
||||
_KEY_ALGO_16 = {'3des2': 0x05, 'aes': 0x02}
|
||||
# The 16-byte algorithm choices as the API accepts them (derived from the map
|
||||
# above, so the endpoint's validation and the parser cannot drift apart).
|
||||
ALGORITHM16_CHOICES = tuple(_KEY_ALGO_16)
|
||||
KEY_SIZES = (8, 16, 24)
|
||||
PSK_KEY_HEX_LEN = 32 # the store's PSK contract: 32 hex chars (16 bytes)
|
||||
MAX_KEYSET = presets.KVN_MAX
|
||||
SKIP_REPORT_MAX = 20 # the report names the first N skips, then counts
|
||||
|
||||
|
||||
def _tokens(line):
|
||||
"""The whitespace tokens of a line, with a leading ``word:`` prefix
|
||||
stripped from the first token (``var_out:IMSI ICCID ...`` -> IMSI, ICCID).
|
||||
A config line like ``ICCID:897...`` therefore yields a single value token
|
||||
and can never be mistaken for the column line."""
|
||||
parts = str(line or '').split()
|
||||
if parts and ':' in parts[0]:
|
||||
parts[0] = parts[0].split(':')[-1]
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
# A column name: an identifier (IMSI, ICCID, KIC1, PSK_ID, ...). The shape
|
||||
# check keeps a configuration line that names a value next to a column
|
||||
# (``ICCID 897...`` - the value is not an identifier) from being read as the
|
||||
# column line.
|
||||
_COLUMN_NAME_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]*')
|
||||
|
||||
# The columns the presets use, beyond ICCID itself. A candidate header must
|
||||
# carry at least one of them: a line with ICCID and nothing usable cannot
|
||||
# yield a card (every keyset needs a KICn/KIDn pair), so it is not the header.
|
||||
_FIXED_COLUMNS = ('ADM', 'PIN1', 'PIN2', 'PUK1', 'PUK2', 'PSK', 'PSKID', 'PSK_ID')
|
||||
|
||||
|
||||
def _is_known_column(name):
|
||||
"""A column the import consumes: a fixed name or a numbered KIC/KID."""
|
||||
if name in _FIXED_COLUMNS:
|
||||
return True
|
||||
m = re.fullmatch(r'(?:KIC|KID)([0-9]{1,2})', name)
|
||||
return bool(m) and 1 <= int(m.group(1)) <= MAX_KEYSET
|
||||
|
||||
|
||||
def find_columns(text):
|
||||
"""The column-name line of an outfile: the first line whose tokens all
|
||||
look like column names, include ``ICCID`` and carry at least one other
|
||||
column the import consumes (at least two tokens in total). Returns
|
||||
``(columns, line_index)`` with the column names uppercased, or
|
||||
``(None, -1)``."""
|
||||
for i, line in enumerate(str(text or '').splitlines()):
|
||||
toks = _tokens(line)
|
||||
if len(toks) < 2 or not all(_COLUMN_NAME_RE.fullmatch(t) for t in toks):
|
||||
continue
|
||||
names = [t.upper() for t in toks]
|
||||
if 'ICCID' not in names:
|
||||
continue
|
||||
if not any(n != 'ICCID' and _is_known_column(n) for n in names):
|
||||
continue
|
||||
return names, i
|
||||
return None, -1
|
||||
|
||||
|
||||
def _column_index(columns, name):
|
||||
try:
|
||||
return columns.index(name)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
|
||||
def _keyset_columns(columns):
|
||||
"""The numbered KIC/KID column pairs: ``{n: (kic_index, kid_index)}`` for
|
||||
the numbers both sides carry."""
|
||||
kic, kid = {}, {}
|
||||
for i, c in enumerate(columns):
|
||||
m = re.fullmatch(r'KIC([0-9]{1,2})', c)
|
||||
if m and 1 <= int(m.group(1)) <= MAX_KEYSET:
|
||||
kic[int(m.group(1))] = i
|
||||
m = re.fullmatch(r'KID([0-9]{1,2})', c)
|
||||
if m and 1 <= int(m.group(1)) <= MAX_KEYSET:
|
||||
kid[int(m.group(1))] = i
|
||||
return {n: (kic[n], kid[n]) for n in sorted(set(kic) & set(kid))}
|
||||
|
||||
|
||||
def default_tars(tar_keyset=None):
|
||||
"""The TAR table for the imported cards: the three mandatory role entries
|
||||
with their spec defaults and MSL 16, each with the default keyset (None
|
||||
leaves it to the store's single-keyset fill)."""
|
||||
out = []
|
||||
for role in presets.TAR_ROLES:
|
||||
d = presets.TAR_ROLE_DEFAULTS[role]
|
||||
out.append({'role': role, 'tar': d['tar'], 'msl': d['msl'],
|
||||
'desc': '', 'keyset': tar_keyset})
|
||||
return out
|
||||
|
||||
|
||||
def normalise_tars(tars, tar_keyset=None):
|
||||
"""The caller's TAR table (or the role defaults) cleaned: the roles kept,
|
||||
the TAR/MSL uppercased, the global default keyset applied to every
|
||||
entry. Raises ValueError on a malformed table (the caller answers 400)."""
|
||||
entries = tars if isinstance(tars, list) and tars else default_tars()
|
||||
out = []
|
||||
for t in entries:
|
||||
if not isinstance(t, dict):
|
||||
raise ValueError('invalid TAR entry')
|
||||
role = str(t.get('role') or '').strip()
|
||||
tar = re.sub(r'\s', '', str(t.get('tar') or '')).upper()
|
||||
msl = re.sub(r'\s', '', str(t.get('msl') or '')).upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{6}', tar):
|
||||
raise ValueError('TAR must be three hex bytes: %r' % tar)
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', msl):
|
||||
raise ValueError('MSL must be one hex byte: %r' % msl)
|
||||
ks_raw = t.get('keyset')
|
||||
if ks_raw in (None, ''):
|
||||
keyset = None
|
||||
else:
|
||||
keyset = presets.normalise_keyset_ref(ks_raw)
|
||||
if keyset is None:
|
||||
raise ValueError('keyset must be 1-%02X: %r'
|
||||
% (presets.KVN_MAX, ks_raw))
|
||||
out.append({'role': role if role in presets.TAR_ROLES else '',
|
||||
'tar': tar, 'msl': msl,
|
||||
'desc': str(t.get('desc') or '').strip(),
|
||||
'keyset': keyset or (tar_keyset or None)})
|
||||
seen = set()
|
||||
for t in out:
|
||||
if t['tar'] in seen:
|
||||
raise ValueError('duplicate TAR %s' % t['tar'])
|
||||
seen.add(t['tar'])
|
||||
return out
|
||||
|
||||
|
||||
def _key_algo(size_bytes, algorithm16):
|
||||
if size_bytes in _KEY_ALGO:
|
||||
return _KEY_ALGO[size_bytes]
|
||||
if size_bytes == 16:
|
||||
return _KEY_ALGO_16.get(str(algorithm16 or '3des2').strip().lower(), 0x05)
|
||||
return None
|
||||
|
||||
|
||||
def _hex_value(tok):
|
||||
"""A row token as an even-length uppercase hex string, or ''."""
|
||||
v = re.sub(r'[^0-9A-Fa-f]', '', str(tok or '')).upper()
|
||||
return v if v and len(v) % 2 == 0 else ''
|
||||
|
||||
|
||||
def parse_outfile(text, name='', algorithm16='3des2', tars=None, tar_keyset=None):
|
||||
"""Parse an outfile into preset dicts (the store's shape).
|
||||
|
||||
Returns ``{'presets': [...], 'report': {...}}``. Every skipped row or key
|
||||
is named in the report (up to ``SKIP_REPORT_MAX`` lines, then counted) -
|
||||
nothing is silently dropped. Raises ValueError on a malformed TAR table.
|
||||
"""
|
||||
columns, line_index = find_columns(text)
|
||||
report = {'columns': columns or [], 'header_lines': max(line_index, 0),
|
||||
'rows': 0, 'cards': 0, 'keysets': [], 'tars': 0,
|
||||
'skipped': [], 'skipped_more': 0}
|
||||
if columns is None:
|
||||
report['error'] = ('no column-name line found - the data must start '
|
||||
'with a whitespace-separated header containing ICCID')
|
||||
return {'presets': [], 'report': report}
|
||||
idx = {c: _column_index(columns, c)
|
||||
for c in ('ICCID', 'ADM', 'PIN1', 'PIN2', 'PUK1', 'PUK2',
|
||||
'PSK', 'PSKID', 'PSK_ID')}
|
||||
if idx['PSKID'] < 0:
|
||||
idx['PSKID'] = idx['PSK_ID']
|
||||
pairs = _keyset_columns(columns)
|
||||
report['keysets'] = ['KIC%d/KID%d' % (n, n) for n in pairs]
|
||||
tars_base = normalise_tars(tars, tar_keyset)
|
||||
report['tars'] = len(tars_base)
|
||||
|
||||
def skip(msg):
|
||||
if len(report['skipped']) < SKIP_REPORT_MAX:
|
||||
report['skipped'].append(msg)
|
||||
else:
|
||||
report['skipped_more'] += 1
|
||||
|
||||
presets_out = []
|
||||
lines = str(text or '').splitlines()
|
||||
for row_no, line in enumerate(lines[line_index + 1:], start=line_index + 2):
|
||||
# The data rows split plainly: the ``word:`` prefix strip belongs to
|
||||
# the header detection and must not touch a value.
|
||||
toks = str(line).split()
|
||||
if not toks:
|
||||
continue
|
||||
# The file is positional: an empty value drops its token, so a row whose
|
||||
# token count differs from the header's column count carries an empty
|
||||
# (or extra) value somewhere. The file is invalid then - it is never
|
||||
# partially imported (v3.29.2).
|
||||
if len(toks) != len(columns):
|
||||
report['error'] = ('line %d has %d values, the header declares %d '
|
||||
'columns - the file is invalid'
|
||||
% (row_no, len(toks), len(columns)))
|
||||
return {'presets': [], 'report': report}
|
||||
report['rows'] += 1
|
||||
iccid = presets.normalize_iccid(toks[idx['ICCID']]) if idx['ICCID'] >= 0 else ''
|
||||
if not iccid:
|
||||
skip('line %d: no ICCID' % row_no)
|
||||
continue
|
||||
keysets = []
|
||||
for n, (ki, di) in pairs.items():
|
||||
kic_key = _hex_value(toks[ki])
|
||||
kid_key = _hex_value(toks[di])
|
||||
if not kic_key or not kid_key:
|
||||
# The header declares this keyset: every row must carry a usable
|
||||
# value for it (v3.29.2)
|
||||
report['error'] = ('line %d: keyset %d has no usable KIC/KID '
|
||||
'value - the file is invalid' % (row_no, n))
|
||||
return {'presets': [], 'report': report}
|
||||
if len(kic_key) != len(kid_key):
|
||||
skip('line %d: keyset %d KIC/KID sizes differ' % (row_no, n))
|
||||
continue
|
||||
algo = _key_algo(len(kic_key) // 2, algorithm16)
|
||||
if algo is None:
|
||||
skip('line %d: keyset %d has an unsupported key size (%d bytes)'
|
||||
% (row_no, n, len(kic_key) // 2))
|
||||
continue
|
||||
byte = '%02X' % ((n << 4) | algo)
|
||||
keysets.append({'kic': byte, 'kid': byte,
|
||||
'kicKey': kic_key, 'kidKey': kid_key})
|
||||
if not keysets:
|
||||
skip('line %d: no usable keyset' % row_no)
|
||||
continue
|
||||
# Every TAR gets the caller's global default keyset; without one the
|
||||
# card's first keyset is used (a multi-keyset card would otherwise
|
||||
# fail the store's "a non-zero MSL needs a default keyset" rule).
|
||||
# A per-entry keyset (chosen in the preview) wins; the caller's global
|
||||
# default - else the card's first keyset - fills the rest.
|
||||
card_keyset = tar_keyset or presets.keyset_kvn(keysets[0])
|
||||
card_tars = [dict(t, keyset=t.get('keyset') or card_keyset)
|
||||
for t in tars_base]
|
||||
entry = {'name': str(name or '').strip() or iccid, 'iccid': iccid,
|
||||
'keysets': keysets, 'tars': card_tars}
|
||||
for field, col in (('pin1', 'PIN1'), ('pin2', 'PIN2'),
|
||||
('puk1', 'PUK1'), ('puk2', 'PUK2')):
|
||||
i = idx[col]
|
||||
raw = str(toks[i]).strip() if 0 <= i < len(toks) else ''
|
||||
if not raw:
|
||||
continue # the column is simply absent from this row
|
||||
v = re.sub(r'\D', '', raw)
|
||||
if re.fullmatch(r'[0-9]{4,8}', v):
|
||||
entry[field] = v
|
||||
else:
|
||||
skip('line %d: %s %r is not 4-8 digits - dropped'
|
||||
% (row_no, col, raw))
|
||||
i = idx['ADM']
|
||||
if 0 <= i < len(toks) and toks[i]:
|
||||
# The ADM column carries the key's hex value itself (the length
|
||||
# varies) - the form the preset store and /api/verify-adm use.
|
||||
adm = re.sub(r'[^0-9A-Fa-f]', '', str(toks[i])).upper()
|
||||
# Byte pairs, exactly like /api/verify-adm: an odd length is a half
|
||||
# byte and the verifier would always refuse the stored value
|
||||
# (v3.28.3).
|
||||
if re.fullmatch(presets.ADM_PATTERN, adm):
|
||||
entry['adm'] = adm
|
||||
else:
|
||||
skip('line %d: ADM is not 4-16 hex digits in byte pairs - dropped'
|
||||
% row_no)
|
||||
i = idx['PSK']
|
||||
if 0 <= i < len(toks) and toks[i]:
|
||||
psk = _hex_value(toks[i])
|
||||
if len(psk) == PSK_KEY_HEX_LEN:
|
||||
entry['pskKey'] = psk
|
||||
pi = idx['PSKID']
|
||||
ident = str(toks[pi]).strip() if 0 <= pi < len(toks) else ''
|
||||
entry['pskIdentity'] = ident or iccid
|
||||
else:
|
||||
skip('line %d: PSK is not 16 bytes (32 hex) - dropped' % row_no)
|
||||
presets_out.append(entry)
|
||||
report['cards'] = len(presets_out)
|
||||
return {'presets': presets_out, 'report': report}
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Server-side card presets: SCP80/SCP81 key material, counters and per-card
|
||||
parameters, kept in one JSON file.
|
||||
|
||||
The presets used to live in the browser's localStorage and the PWA wrote the
|
||||
SCP80 counter back after every operation. The counter is card state (the card
|
||||
rejects a packet whose counter is not above its own), so the server - which
|
||||
performs every card operation and already computes the accepted counter - now
|
||||
owns the store: an accepted counter is persisted in the same operation, and a
|
||||
closed tab, a lost response or a second browser window can no longer lose an
|
||||
increment.
|
||||
|
||||
A preset holds a **TAR table**: every Remote Management application the card
|
||||
exposes is addressed by its TAR, and each carries its own **Minimum Security
|
||||
Level** (MSL) - the "Minimum SPI1" the card checks before it processes a
|
||||
secured packet (TS 102 226 6.1/8.2.1.3.2.4; a too-low SPI1 is answered with
|
||||
response status 0A "Insufficient security level"). The three role entries
|
||||
(`isd`, `uiccRfm`, `usimRfm`) are mandatory - the Cards form pre-settles their
|
||||
values - and further TARs can be added with an optional description. A packet
|
||||
for a TAR derives its SPI1 from that TAR's MSL; the preset no longer carries a
|
||||
card-wide SPI1/SPI2 (SPI2 is an operation property: whether and how a Proof of
|
||||
Receipt is requested).
|
||||
|
||||
The optional **card codes** (`pin1`/`puk1`/`pin2`/`puk2`/`adm`) are stored for
|
||||
later use by the card operations (nothing consumes them yet); the PIN/PUK codes
|
||||
are 4-8 decimal digits, the ADM key is hex or ASCII digits.
|
||||
|
||||
The optional **CAT (USAT) release** (`release`, 4-18) names the release the
|
||||
card implements: the release guard hides CAT items introduced after it (the
|
||||
Simulator's event parameters, see `catrelease`). Empty means unspecified and
|
||||
resolves to Rel-12 (`catrelease.DEFAULT_RELEASE`) - an unset release never
|
||||
filters anything away.
|
||||
|
||||
A preset holds **several keysets**; the b8..b5 nibble of KIc/KID numbers them
|
||||
(TS 102 225 5.1.2/A.2). Each keyset has its own KIc/KID keys and its own
|
||||
counter - "a dedicated counter shall be associated to each key version"
|
||||
(TS 102 225 Annex A.1, the spec's wording). The two bytes of a keyset must
|
||||
carry the same number (A.2: the card rejects a mismatch with "Unidentified
|
||||
security error"); number '00' is reserved and not a keyset (it means "no
|
||||
security" in a packet, which is a form-level choice, not a preset).
|
||||
|
||||
Layout (``--card-presets`` overrides the file; ``Path.home()`` resolves the
|
||||
same way on Linux, macOS and Windows)::
|
||||
|
||||
~/.pysim-simple-server/card_presets.json
|
||||
~/.pysim-simple-server/card_presets.json.audit.jsonl (counter changes)
|
||||
|
||||
Writes are atomic (temp file + fsync + ``os.replace``) and every mutation
|
||||
holds an ``RLock``, so request handlers and the operation threads can share one
|
||||
store. Presets carry a stable uuid - never array indices. The JSON is
|
||||
deliberately human-readable: hand-editing a counter or moving the file between
|
||||
machines is part of the workbench workflow, and the Cards tab's export/import
|
||||
uses the same shape (which is also how old localStorage presets move over -
|
||||
there is no automatic migration). v3.8.0's flat ``kic/kid/kicKey/kidKey/cntr``
|
||||
shape is converted into a single keyset, and a v3.9.1 preset's card-wide
|
||||
``spi1``/``tar``/``uiccTar``/``usimTar`` become the three role entries (the
|
||||
spi1 as their MSL) - on load and on import alike.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_DIR = '.pysim-simple-server'
|
||||
DEFAULT_FILENAME = 'card_presets.json'
|
||||
SCHEMA_VERSION = 6
|
||||
|
||||
# The fields a preset carries: the localStorage shape plus the id. The OTA key
|
||||
# material lives in `keysets` - the b8..b5 nibble of KIc/KID numbers them,
|
||||
# each with its own counter (TS 102 225 Annex A.1: "a dedicated counter shall
|
||||
# be associated to each key version"); the TAR table lives in `tars` (see
|
||||
# TAR_ROLES). The optional card codes (PIN/PUK/ADM) are stored for later use.
|
||||
PRESET_FIELDS = ('name', 'iccid', 'release', 'pin1', 'puk1', 'pin2', 'puk2', 'adm',
|
||||
'pskIdentity', 'pskKey')
|
||||
# The v3.8.0 flat key fields: converted into a single keyset on load/import.
|
||||
LEGACY_KEY_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
|
||||
KEYSET_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
|
||||
HEX_FIELDS = ('adm', 'pskKey')
|
||||
# The card codes: PIN/PUK are 4-8 decimal digits (validated in _validate),
|
||||
# ADM is hex or ASCII digits (HEX_FIELDS).
|
||||
DIGIT_FIELDS = ('pin1', 'puk1', 'pin2', 'puk2')
|
||||
KEYSET_HEX_FIELDS = ('kic', 'kid', 'kicKey', 'kidKey', 'cntr')
|
||||
# The TAR table: the three mandatory role entries (their values are pre-settled
|
||||
# in the Cards form) and free entries with an optional description. Every
|
||||
# entry carries the TAR, its MSL (Minimum SPI1, one hex byte) and its **default
|
||||
# keyset** (v3.26.0): the keyset every operation addressed to that TAR uses.
|
||||
# The keyset is mandatory for any non-zero MSL (the counters are keyset-linked,
|
||||
# TS 102 225 A.1) and may be omitted only for MSL 00 - where setting one is
|
||||
# still allowed, to pass its KIc/KID in the packet (some cards reject the
|
||||
# no-security '00/00' form even when SPI declares no security).
|
||||
TAR_ROLES = ('isd', 'uiccRfm', 'usimRfm')
|
||||
TAR_ROLE_DEFAULTS = {
|
||||
'isd': {'tar': '000000', 'msl': '16'},
|
||||
'uiccRfm': {'tar': 'B00000', 'msl': '16'},
|
||||
'usimRfm': {'tar': 'B00001', 'msl': '16'},
|
||||
}
|
||||
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
|
||||
COUNTER_WIDTH = 10 # ... shown as 10 hex digits
|
||||
KVN_MAX = 0x0F # keyset number '00' is reserved: no key set, no counter
|
||||
# The ADM's shape: 4-16 hex digits in byte pairs (the verifier pads it with 'f'
|
||||
# to the 8 CHV bytes, so an odd length or a longer value can never verify).
|
||||
# Shared by /api/verify-adm and the outfile import so the two cannot drift.
|
||||
ADM_PATTERN = r'(?:[0-9A-F]{2}){2,8}'
|
||||
|
||||
|
||||
def default_path():
|
||||
"""The default store: ``~/.pysim-simple-server/card_presets.json``."""
|
||||
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
|
||||
|
||||
|
||||
def keyset_kvn(keyset):
|
||||
"""The keyset number: the high nibble of KIc (bits b8..b5 of KIc/KID,
|
||||
TS 102 225 5.1.2/A.2 - both bytes must agree, so KIc is enough). None
|
||||
when the byte is unusable."""
|
||||
kic = str((keyset or {}).get('kic') or '').strip().upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', kic):
|
||||
return None
|
||||
return int(kic, 16) >> 4
|
||||
|
||||
|
||||
def _swap_nibbles(s):
|
||||
"""Nibble-swap a hex string; an odd trailing nibble becomes 'F' (the JS
|
||||
``swapNibbles`` the PWA uses)."""
|
||||
out = []
|
||||
for i in range(0, len(s), 2):
|
||||
out.append(s[i + 1] if i + 1 < len(s) else 'F')
|
||||
out.append(s[i])
|
||||
return ''.join(out).upper()
|
||||
|
||||
|
||||
def normalize_iccid(value):
|
||||
"""ICCID -> plain digits, accepting every stored form (the PWA's
|
||||
``cardsNormIccid``): digits, spaced digits, the digits with the odd-nibble
|
||||
F padding, or the raw nibble-swapped EF hex.
|
||||
|
||||
The trailing F (or Fs) is the odd-nibble padding: when the rest is digits
|
||||
only, the value is the plain digit form (the card vendors' outfiles print
|
||||
it that way); otherwise - the rest carries A-F - it is the raw form and
|
||||
the swap applies. A raw EF value never ends in F (its pad nibble lands in
|
||||
the high half of the last byte), so the rule is unambiguous. Empty string
|
||||
means "no ICCID"."""
|
||||
s = re.sub(r'[\s-]', '', str(value or '')).upper()
|
||||
if not s:
|
||||
return ''
|
||||
stripped = re.sub(r'F+$', '', s)
|
||||
if re.search(r'[A-F]', stripped):
|
||||
s = _swap_nibbles(re.sub(r'[^0-9A-F]', '', s))
|
||||
s = re.sub(r'F+$', '', s)
|
||||
else:
|
||||
s = stripped
|
||||
return re.sub(r'^0+', '', re.sub(r'\D', '', s))
|
||||
|
||||
|
||||
def counter_value(cntr):
|
||||
"""The counter as an int, or None when it is not hex."""
|
||||
try:
|
||||
return int(re.sub(r'[^0-9A-Fa-f]', '', str(cntr or '')), 16)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def counter_hex(value):
|
||||
"""The fixed-width 10-hex-digit counter form the card protocol uses."""
|
||||
v = counter_value(value)
|
||||
if v is None:
|
||||
return str(value or '').strip().upper()
|
||||
return '%0*X' % (COUNTER_WIDTH, v % (1 << COUNTER_BITS))
|
||||
|
||||
|
||||
def counter_ahead(old, new):
|
||||
"""True when ``new`` is a plausible forward step from ``old``: the counter
|
||||
only moves forward, and a value that wrapped around the 40-bit field
|
||||
(top of the range -> small value) counts as forward."""
|
||||
o, n = counter_value(old), counter_value(new)
|
||||
if o is None or n is None:
|
||||
return False
|
||||
if n > o:
|
||||
return True
|
||||
return (o - n) > (1 << (COUNTER_BITS - 1))
|
||||
|
||||
|
||||
def tar_entry(preset, tar_hex):
|
||||
"""The preset's TAR entry whose TAR matches `tar_hex` (case-insensitive),
|
||||
or None."""
|
||||
want = re.sub(r'\s', '', str(tar_hex or '')).upper()
|
||||
if not want:
|
||||
return None
|
||||
for t in (preset or {}).get('tars') or []:
|
||||
if str(t.get('tar') or '').upper() == want:
|
||||
return dict(t)
|
||||
return None
|
||||
|
||||
|
||||
def role_tar(preset, role):
|
||||
"""The TAR of a role entry ('isd' | 'uiccRfm' | 'usimRfm'), or ''."""
|
||||
for t in (preset or {}).get('tars') or []:
|
||||
if t.get('role') == role:
|
||||
return str(t.get('tar') or '')
|
||||
return ''
|
||||
|
||||
|
||||
def tar_msl(preset, tar_hex):
|
||||
"""The MSL (Minimum SPI1) of a TAR, or '' when the preset has no such
|
||||
entry or no MSL for it."""
|
||||
return str((tar_entry(preset, tar_hex) or {}).get('msl') or '')
|
||||
|
||||
|
||||
def tar_keyset(preset, tar_hex):
|
||||
"""The default keyset number (int 1-15) of a TAR, or None when the preset
|
||||
has no such entry or the entry carries no keyset (allowed for an MSL 00
|
||||
entry - the packet is then built keyless; v3.26.0)."""
|
||||
return (tar_entry(preset, tar_hex) or {}).get('keyset') or None
|
||||
|
||||
|
||||
def normalise_keyset_ref(v):
|
||||
"""A TAR's default keyset number (int 1-15) or None. Accepts an int and
|
||||
the decimal ('1'..'15') or hex-ish ('01'..'0F') string forms; an unusable
|
||||
value becomes None - the validation reports a missing keyset where one is
|
||||
mandatory. Shared by the store and the outfile import (v3.29.1)."""
|
||||
if v is None or v == '':
|
||||
return None
|
||||
s = str(v).strip().upper()
|
||||
if re.fullmatch(r'[0-9]{1,2}', s):
|
||||
n = int(s, 10)
|
||||
if 1 <= n <= KVN_MAX:
|
||||
return n
|
||||
if re.fullmatch(r'[0-9A-F]{1,2}', s):
|
||||
n = int(s, 16)
|
||||
if 1 <= n <= KVN_MAX:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
class PresetError(Exception):
|
||||
"""A refused mutation (validation, duplicate ICCID, ...)."""
|
||||
|
||||
|
||||
class PresetStore:
|
||||
"""The card preset store. All public methods are thread-safe and return
|
||||
copies - callers can never mutate the store's own state."""
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.path = Path(path) if path else default_path()
|
||||
self.audit_path = self.path.with_name(self.path.name + '.audit.jsonl')
|
||||
self._lock = threading.RLock()
|
||||
self._presets = []
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------------ disk
|
||||
def _load(self):
|
||||
try:
|
||||
raw = self.path.read_text(encoding='utf-8')
|
||||
except FileNotFoundError:
|
||||
self._presets = []
|
||||
return
|
||||
except OSError as e:
|
||||
sys.stderr.write('PRESETS: cannot read %s: %s\n' % (self.path, e))
|
||||
self._presets = []
|
||||
return
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError as e:
|
||||
# Never silently overwrite an unreadable store: keep the file for
|
||||
# the user, start empty (the next save would destroy it).
|
||||
bad = self.path.with_name(self.path.name + '.bad-%s'
|
||||
% time.strftime('%Y%m%d-%H%M%S'))
|
||||
try:
|
||||
self.path.replace(bad)
|
||||
sys.stderr.write('PRESETS: %s is not valid JSON (%s) - kept as %s, '
|
||||
'starting empty\n' % (self.path, e, bad))
|
||||
except OSError:
|
||||
sys.stderr.write('PRESETS: %s is not valid JSON (%s)\n' % (self.path, e))
|
||||
self._presets = []
|
||||
return
|
||||
items = data.get('presets') if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
self._presets = [self._normalise(p) for p in items if isinstance(p, dict)]
|
||||
|
||||
def _save(self):
|
||||
data = {'version': SCHEMA_VERSION, 'presets': self._presets}
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(prefix=self.path.name + '.',
|
||||
dir=str(self.path.parent))
|
||||
try:
|
||||
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write('\n')
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp_name, self.path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
# Make the rename durable. Best effort: opening a directory is not
|
||||
# portable (Windows) - a failure here never fails the mutation.
|
||||
dir_fd = os.open(str(self.path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _audit(self, pid, name, old, new, source, kvn=None):
|
||||
"""One JSONL line per counter change (the keyset number included - each
|
||||
keyset has its own counter): the forensic trail for "did an increment
|
||||
get lost?" questions."""
|
||||
try:
|
||||
line = json.dumps({'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
'id': pid, 'name': name, 'kvn': kvn,
|
||||
'old': old, 'new': new,
|
||||
'source': source}, ensure_ascii=False)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.audit_path, 'a', encoding='utf-8') as fh:
|
||||
fh.write(line + '\n')
|
||||
except OSError as e:
|
||||
sys.stderr.write('PRESETS: audit write failed: %s\n' % e)
|
||||
|
||||
# ------------------------------------------------------------- shaping
|
||||
def _normalise(self, fields):
|
||||
"""The stored shape: the known fields, cleaned; unknown keys dropped."""
|
||||
out = {}
|
||||
for k in PRESET_FIELDS:
|
||||
v = fields.get(k)
|
||||
v = '' if v is None else str(v).strip()
|
||||
if k in HEX_FIELDS:
|
||||
v = re.sub(r'\s', '', v).upper()
|
||||
elif k in DIGIT_FIELDS:
|
||||
v = re.sub(r'\s', '', v)
|
||||
out[k] = v
|
||||
out['tars'] = self._normalise_tars(fields)
|
||||
# Keysets: a v3.8.0 preset (flat kic/kid/kicKey/kidKey/cntr) converts
|
||||
# into a single keyset, so old files and exports keep working.
|
||||
raw = fields.get('keysets')
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
if any(str(fields.get(k) or '').strip() for k in LEGACY_KEY_FIELDS):
|
||||
raw = [{k: fields.get(k) for k in LEGACY_KEY_FIELDS}]
|
||||
out['keysets'] = [self._normalise_keyset(ks) for ks in raw
|
||||
if isinstance(ks, dict)]
|
||||
# v3.26.0: a preset with a single keyset fills every TAR entry that has
|
||||
# no default keyset with it - the TARs' keysets drive the operations.
|
||||
nums = [keyset_kvn(ks) for ks in out['keysets']]
|
||||
nums = [n for n in nums if n]
|
||||
if len(nums) == 1:
|
||||
for t in out['tars']:
|
||||
if not t.get('keyset'):
|
||||
t['keyset'] = nums[0]
|
||||
pid = str(fields.get('id') or '').strip().lower()
|
||||
out['id'] = pid or uuid.uuid4().hex
|
||||
return out
|
||||
|
||||
def _normalise_tars(self, fields):
|
||||
"""The preset's TAR table: the three mandatory role entries (values
|
||||
filled from the defaults when empty, so a partial API call and the
|
||||
pre-settled form both work) followed by the free entries in input
|
||||
order. A v3.9.1 preset (tar/uiccTar/usimTar + the card-wide spi1)
|
||||
converts: the spi1 becomes the MSL of all three roles."""
|
||||
raw = fields.get('tars')
|
||||
entries = []
|
||||
if isinstance(raw, list):
|
||||
for t in raw:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
role = str(t.get('role') or '').strip()
|
||||
entries.append({
|
||||
'role': role if role in TAR_ROLES else '',
|
||||
'tar': re.sub(r'\s', '', str(t.get('tar') or '')).upper(),
|
||||
'msl': re.sub(r'\s', '', str(t.get('msl') or '')).upper(),
|
||||
'desc': str(t.get('desc') or '').strip(),
|
||||
'keyset': normalise_keyset_ref(t.get('keyset')),
|
||||
})
|
||||
else:
|
||||
msl = re.sub(r'\s', '', str(fields.get('spi1') or '')).upper()
|
||||
for role, src in (('isd', 'tar'), ('uiccRfm', 'uiccTar'),
|
||||
('usimRfm', 'usimTar')):
|
||||
entries.append({
|
||||
'role': role,
|
||||
'tar': re.sub(r'\s', '', str(fields.get(src) or '')).upper(),
|
||||
'msl': msl,
|
||||
'desc': '',
|
||||
'keyset': None,
|
||||
})
|
||||
out = []
|
||||
for role in TAR_ROLES:
|
||||
cur = next((e for e in entries if e['role'] == role), None)
|
||||
d = TAR_ROLE_DEFAULTS[role]
|
||||
out.append({'role': role,
|
||||
'tar': (cur or {}).get('tar') or d['tar'],
|
||||
'msl': (cur or {}).get('msl') or d['msl'],
|
||||
'desc': '',
|
||||
'keyset': (cur or {}).get('keyset')})
|
||||
for e in entries:
|
||||
if e['role']:
|
||||
continue
|
||||
if not (e['tar'] or e['msl'] or e['desc'] or e.get('keyset')):
|
||||
continue # a blank editor row
|
||||
# free entries carry no `role`: the three roles are the fixed ones
|
||||
out.append({'tar': e['tar'], 'msl': e['msl'], 'desc': e['desc'],
|
||||
'keyset': e.get('keyset')})
|
||||
return out
|
||||
|
||||
def _normalise_keyset(self, ks):
|
||||
"""One keyset, cleaned. The counter is kept as typed here and
|
||||
validated + padded in _validate_keyset() - a bad value must be
|
||||
refused, never silently masked."""
|
||||
out = {}
|
||||
for k in KEYSET_FIELDS:
|
||||
v = ks.get(k)
|
||||
v = '' if v is None else str(v).strip()
|
||||
if k in KEYSET_HEX_FIELDS:
|
||||
v = re.sub(r'\s', '', v).upper()
|
||||
out[k] = v
|
||||
out['cntr'] = out['cntr'] or '1'
|
||||
return out
|
||||
|
||||
def _validate(self, p, skip_id=None):
|
||||
if not p['name']:
|
||||
raise PresetError('name is required')
|
||||
if bool(p['pskIdentity']) != bool(p['pskKey']):
|
||||
raise PresetError('PSK identity and PSK key must be set together')
|
||||
if p['pskKey'] and not re.fullmatch(r'[0-9A-F]{32}', p['pskKey']):
|
||||
raise PresetError('PSK key must be 32 hex characters')
|
||||
if p['release'] and (not re.fullmatch(r'[0-9]{1,2}', p['release'])
|
||||
or not 4 <= int(p['release']) <= 18):
|
||||
raise PresetError('release must be 4-18 (or empty for the Rel-12 default)')
|
||||
for k in DIGIT_FIELDS:
|
||||
if p[k] and not re.fullmatch(r'[0-9]{4,8}', p[k]):
|
||||
raise PresetError('%s must be 4-8 decimal digits (or empty)'
|
||||
% k.upper())
|
||||
if not p['keysets']:
|
||||
raise PresetError('at least one keyset is required')
|
||||
seen = set()
|
||||
for ks in p['keysets']:
|
||||
self._validate_keyset(ks)
|
||||
kvn = keyset_kvn(ks)
|
||||
if kvn in seen:
|
||||
raise PresetError('duplicate keyset number %02X in this preset' % kvn)
|
||||
seen.add(kvn)
|
||||
seen_tars = set()
|
||||
for t in p['tars']:
|
||||
if not re.fullmatch(r'[0-9A-F]{6}', t['tar']):
|
||||
raise PresetError('TAR must be three hex bytes: %r' % t['tar'])
|
||||
if not t['msl']:
|
||||
raise PresetError('TAR %s needs its MSL (Minimum SPI1, one hex '
|
||||
'byte, TS 102 226 8.2.1.3.2.4)' % t['tar'])
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', t['msl']):
|
||||
raise PresetError('MSL must be one hex byte: %r' % t['msl'])
|
||||
if t.get('keyset'):
|
||||
if not any(keyset_kvn(ks) == t['keyset'] for ks in p['keysets']):
|
||||
raise PresetError('TAR %s names keyset %02X, which this '
|
||||
'preset does not define'
|
||||
% (t['tar'], t['keyset']))
|
||||
elif t['msl'] != '00':
|
||||
raise PresetError('TAR %s needs a default keyset (its MSL %s '
|
||||
'requires security) - set it in the Cards tab'
|
||||
% (t['tar'], t['msl']))
|
||||
if t['tar'] in seen_tars:
|
||||
raise PresetError('duplicate TAR %s in this preset' % t['tar'])
|
||||
seen_tars.add(t['tar'])
|
||||
norm = normalize_iccid(p['iccid'])
|
||||
if norm:
|
||||
for other in self._presets:
|
||||
if other['id'] == skip_id:
|
||||
continue
|
||||
if normalize_iccid(other['iccid']) == norm:
|
||||
raise PresetError('card with this ICCID already exists: %s'
|
||||
% (other['name'] or other['id']))
|
||||
return p
|
||||
|
||||
def _validate_keyset(self, ks):
|
||||
"""One keyset: KIc/KID carry its number in b8..b5 and must agree
|
||||
(TS 102 225 A.2 - a mismatch is rejected by the card), the number is
|
||||
01-0F ('00' means "no security" and has no keys to define, so it is
|
||||
not a preset keyset), both keys are present and the counter is
|
||||
1-10 hex digits (the dedicated counter of that keyset, A.1)."""
|
||||
for key in ('kic', 'kid'):
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', ks[key]):
|
||||
raise PresetError('%s must be one hex byte' % key)
|
||||
kic_v, kid_v = int(ks['kic'], 16) >> 4, int(ks['kid'], 16) >> 4
|
||||
if kic_v != kid_v:
|
||||
raise PresetError('KIc and KID must carry the same keyset number '
|
||||
'(TS 102 225 A.2): %s / %s' % (ks['kic'], ks['kid']))
|
||||
if not 1 <= kic_v <= KVN_MAX:
|
||||
raise PresetError('keyset number must be 01-0F: %s' % ks['kic'])
|
||||
for key in ('kicKey', 'kidKey'):
|
||||
if not ks[key]:
|
||||
raise PresetError('%s is required' % key)
|
||||
if not re.fullmatch(r'[0-9A-F]{1,10}', ks['cntr']):
|
||||
raise PresetError('counter must be 1-10 hex digits')
|
||||
# validated: store the protocol's fixed-width form
|
||||
ks['cntr'] = counter_hex(ks['cntr'])
|
||||
return ks
|
||||
|
||||
def _find(self, pid):
|
||||
want = str(pid or '').strip().lower()
|
||||
if not want:
|
||||
return None
|
||||
for p in self._presets:
|
||||
if p['id'] == want:
|
||||
return p
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------- public
|
||||
def list(self):
|
||||
with self._lock:
|
||||
return [copy.deepcopy(p) for p in self._presets]
|
||||
|
||||
def get(self, pid):
|
||||
with self._lock:
|
||||
p = self._find(pid)
|
||||
return copy.deepcopy(p) if p else None
|
||||
|
||||
def find_by_iccid(self, iccid):
|
||||
norm = normalize_iccid(iccid)
|
||||
if not norm:
|
||||
return None
|
||||
with self._lock:
|
||||
for p in self._presets:
|
||||
if normalize_iccid(p['iccid']) == norm:
|
||||
return copy.deepcopy(p)
|
||||
return None
|
||||
|
||||
def add(self, fields):
|
||||
with self._lock:
|
||||
p = self._normalise(fields or {})
|
||||
if self._find(p['id']):
|
||||
# a caller-supplied id that is already taken never wins: ids
|
||||
# must stay unique (the same guard the import uses)
|
||||
p['id'] = uuid.uuid4().hex
|
||||
self._validate(p)
|
||||
self._presets.append(p)
|
||||
self._save()
|
||||
sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id']))
|
||||
return copy.deepcopy(p)
|
||||
|
||||
def update(self, pid, fields):
|
||||
"""Partial or full update (the human edit path): a counter in a keyset
|
||||
is written as given - the Cards tab is the place to resync it
|
||||
deliberately. The counter an *operation* accepted goes through
|
||||
set_counter()."""
|
||||
with self._lock:
|
||||
cur = self._find(pid)
|
||||
if cur is None:
|
||||
return None
|
||||
merged = copy.deepcopy(cur)
|
||||
for k in PRESET_FIELDS:
|
||||
if isinstance(fields, dict) and k in fields:
|
||||
merged[k] = fields[k]
|
||||
if isinstance(fields, dict) and 'keysets' in fields:
|
||||
merged['keysets'] = fields['keysets']
|
||||
if isinstance(fields, dict) and 'tars' in fields:
|
||||
if not isinstance(fields['tars'], list):
|
||||
raise PresetError('tars must be a list of TAR entries')
|
||||
merged['tars'] = fields['tars']
|
||||
new = self._normalise(merged)
|
||||
new['id'] = cur['id']
|
||||
self._validate(new, skip_id=cur['id'])
|
||||
self._presets[self._presets.index(cur)] = new
|
||||
self._save()
|
||||
for old_ks, new_ks in zip(cur['keysets'], new['keysets']):
|
||||
if old_ks['cntr'] != new_ks['cntr']:
|
||||
kvn = keyset_kvn(new_ks)
|
||||
self._audit(new['id'], new['name'], old_ks['cntr'],
|
||||
new_ks['cntr'], 'edit', kvn)
|
||||
sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (edit)\n'
|
||||
% (new['name'], kvn, old_ks['cntr'], new_ks['cntr']))
|
||||
return copy.deepcopy(new)
|
||||
|
||||
def remove(self, pid):
|
||||
with self._lock:
|
||||
p = self._find(pid)
|
||||
if p is None:
|
||||
return False
|
||||
self._presets.remove(p)
|
||||
self._save()
|
||||
sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id']))
|
||||
return True
|
||||
|
||||
def find_keyset(self, pid, kvn):
|
||||
"""The keyset of a preset with the given number (the b8..b5 nibble of
|
||||
KIc/KID), or None when the preset or the number is unknown."""
|
||||
with self._lock:
|
||||
p = self._find(pid)
|
||||
if p is None:
|
||||
return None
|
||||
for ks in p['keysets']:
|
||||
if keyset_kvn(ks) == kvn:
|
||||
return copy.deepcopy(ks)
|
||||
return None
|
||||
|
||||
def set_counter(self, pid, cntr, source='operation', kvn=None):
|
||||
"""Persist the counter an operation accepted, into the keyset it used
|
||||
(each keyset has its own dedicated counter, TS 102 225 A.1).
|
||||
Monotonic per keyset: the counter only moves
|
||||
forward, so a stale caller can never regress it. `kvn=None` picks the
|
||||
preset's only keyset (a convenience for single-keyset callers).
|
||||
Returns the stored preset (or None when the id is unknown)."""
|
||||
with self._lock:
|
||||
p = self._find(pid)
|
||||
if p is None:
|
||||
return None
|
||||
ks = None
|
||||
if kvn is not None:
|
||||
for cand in p['keysets']:
|
||||
if keyset_kvn(cand) == kvn:
|
||||
ks = cand
|
||||
break
|
||||
if ks is None:
|
||||
sys.stderr.write('PRESETS: no keyset %s in %s (%s)\n'
|
||||
% (kvn, p['name'], source))
|
||||
return copy.deepcopy(p)
|
||||
elif len(p['keysets']) == 1:
|
||||
ks = p['keysets'][0]
|
||||
else:
|
||||
sys.stderr.write('PRESETS: %s has several keysets - the '
|
||||
'counter needs one (%s)\n' % (p['name'], source))
|
||||
return copy.deepcopy(p)
|
||||
new = str(cntr or '').strip().upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{1,10}', new):
|
||||
sys.stderr.write('PRESETS: ignoring malformed counter %r (%s)\n'
|
||||
% (cntr, source))
|
||||
return copy.deepcopy(p)
|
||||
new = counter_hex(new)
|
||||
if new == ks['cntr']:
|
||||
return copy.deepcopy(p)
|
||||
if not counter_ahead(ks['cntr'], new):
|
||||
sys.stderr.write('PRESETS: ignoring backwards counter kvn=%02X '
|
||||
'%s -> %s (%s)\n'
|
||||
% (keyset_kvn(ks), ks['cntr'], new, source))
|
||||
return copy.deepcopy(p)
|
||||
old = ks['cntr']
|
||||
ks['cntr'] = new
|
||||
self._save()
|
||||
self._audit(p['id'], p['name'], old, new, source, keyset_kvn(ks))
|
||||
sys.stderr.write('PRESETS: counter %s kvn=%02X %s -> %s (%s)\n'
|
||||
% (p['name'], keyset_kvn(ks), old, new, source))
|
||||
return copy.deepcopy(p)
|
||||
|
||||
def import_presets(self, items, mode='merge'):
|
||||
"""Import exported (or old localStorage) presets: entries are validated
|
||||
one by one; a duplicate ICCID or an already-used id is skipped, invalid
|
||||
entries are reported instead of failing the whole import. ``replace``
|
||||
wipes the store first."""
|
||||
added, skipped, errors = 0, 0, []
|
||||
with self._lock:
|
||||
if mode == 'replace':
|
||||
self._presets = []
|
||||
for i, entry in enumerate(items or []):
|
||||
if not isinstance(entry, dict):
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
p = self._normalise(entry)
|
||||
if self._find(p['id']):
|
||||
p['id'] = uuid.uuid4().hex
|
||||
self._validate(p)
|
||||
except PresetError as e:
|
||||
skipped += 1
|
||||
errors.append('%s: %s' % (entry.get('name') or ('#' + str(i + 1)), e))
|
||||
continue
|
||||
self._presets.append(p)
|
||||
added += 1
|
||||
if added or mode == 'replace':
|
||||
self._save()
|
||||
sys.stderr.write('PRESETS: import %s: %d added, %d skipped\n'
|
||||
% (mode, added, skipped))
|
||||
return {'added': added, 'skipped': skipped, 'errors': errors[:10]}
|
||||
|
||||
def info(self):
|
||||
with self._lock:
|
||||
return {'path': str(self.path), 'count': len(self._presets),
|
||||
'version': SCHEMA_VERSION}
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Phase B: PSK TLS server and HTTP administration session for SCP81.
|
||||
|
||||
Implements the Remote Administration Server side of GP RAM over HTTP
|
||||
(GPC v2.2 Amendment B):
|
||||
|
||||
- TLS 1.2 with the PSK cipher suites of clause 4.3.2. The handshake and
|
||||
record layer are handled by the stdlib ``ssl`` module through OpenSSL's
|
||||
PSK callbacks (identity -> PSK), so no TLS code lives here.
|
||||
- The HTTP dialog of clause 4.4: parse the Security Domain's POST
|
||||
(``X-Admin-*`` headers, optional body with the previous response string)
|
||||
and answer with 200 + a command string, or 204 No Content to close the
|
||||
administration session.
|
||||
|
||||
The card talks TLS *through* the BIP channel: this server listens on the
|
||||
local redirect target and the BIP terminal proxies the card's SEND/RECEIVE
|
||||
DATA records to it.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
MAX_HEAD = 32 * 1024
|
||||
MAX_BODY = 1 * 1024 * 1024
|
||||
|
||||
# TLS_PSK_* suites from GPC v2.2 Amendment B Table 4-2 / RFC 4279/4785/5487.
|
||||
PSK_CIPHERS = ':'.join([
|
||||
'PSK-AES128-CBC-SHA256', # TLS_PSK_WITH_AES_128_CBC_SHA256 (0x00AE)
|
||||
'PSK-AES128-CBC-SHA', # TLS_PSK_WITH_AES_128_CBC_SHA (0x008C)
|
||||
'PSK-AES256-CBC-SHA', # TLS_PSK_WITH_AES_256_CBC_SHA (0x008D)
|
||||
'PSK-3DES-EDE-CBC-SHA', # TLS_PSK_WITH_3DES_EDE_CBC_SHA (0x008B)
|
||||
'PSK-NULL-SHA256', # TLS_PSK_WITH_NULL_SHA256 (0x00B0)
|
||||
'PSK-NULL-SHA', # TLS_PSK_WITH_NULL_SHA (0x002C)
|
||||
])
|
||||
|
||||
GP_PROTOCOL = 'globalplatform-remote-admin/1.0'
|
||||
GP_CT_COMMAND = 'application/vnd.globalplatform.card-content-mgt;version=1.0'
|
||||
GP_CT_RESPONSE = 'application/vnd.globalplatform.card-content-mgt-response;version=1.0'
|
||||
|
||||
# OpenSSL SSL_OP_NO_ENCRYPT_THEN_MAC (not exposed by the ssl module). The live
|
||||
# card offers the encrypt_then_mac extension but aborts the session with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE as soon as the server echoes it, so keep the
|
||||
# extension out of the ServerHello (verified live 2026-09-15).
|
||||
OP_NO_ENCRYPT_THEN_MAC = 0x00080000
|
||||
|
||||
TLS_VERSIONS = {
|
||||
'1.0': ssl.TLSVersion.TLSv1,
|
||||
'1.1': ssl.TLSVersion.TLSv1_1,
|
||||
'1.2': ssl.TLSVersion.TLSv1_2,
|
||||
}
|
||||
|
||||
|
||||
def _norm_identity(identity):
|
||||
"""Normalize a PSK identity to the str OpenSSL reports (CPython hands it
|
||||
to the PSK callback as a str; bytes are decoded byte-exact)."""
|
||||
if identity is None:
|
||||
return None
|
||||
if isinstance(identity, (bytes, bytearray)):
|
||||
return bytes(identity).decode('latin-1')
|
||||
return str(identity)
|
||||
|
||||
|
||||
def parse_http_request(data):
|
||||
"""Parse an HTTP/1.1 request head (bytes up to CRLFCRLF) into
|
||||
(method, target, headers dict with lower-case names)."""
|
||||
head = data.split(b'\r\n\r\n', 1)[0]
|
||||
lines = head.split(b'\r\n')
|
||||
parts = lines[0].split(b' ')
|
||||
if len(parts) < 3:
|
||||
raise ValueError('malformed request line')
|
||||
method, target = parts[0].decode('latin-1'), parts[1].decode('latin-1')
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
name, _, value = line.partition(b':')
|
||||
headers[name.strip().decode('latin-1').lower()] = value.strip().decode('latin-1')
|
||||
return method, target, headers
|
||||
|
||||
|
||||
def decode_chunked(body):
|
||||
"""Decode a chunked transfer body (RFC 2616 3.6.1)."""
|
||||
out = bytearray()
|
||||
while body:
|
||||
line, _, rest = body.partition(b'\r\n')
|
||||
try:
|
||||
size = int(line.split(b';')[0], 16)
|
||||
except ValueError:
|
||||
raise ValueError('bad chunk size %r' % line[:16])
|
||||
if size == 0:
|
||||
break
|
||||
out.extend(rest[:size])
|
||||
body = rest[size + 2:]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_http_response(status, reason, headers, body=b'', chunked=False,
|
||||
compact=False, connection=None):
|
||||
"""Build an HTTP response. With chunked=True the body is framed as 100-byte
|
||||
chunks (like the reference admin server); with compact=True header names
|
||||
and values are separated by ':' without whitespace, which keeps the whole
|
||||
response inside one card-sized TLS record (<= 256 bytes ciphertext).
|
||||
connection ('close'/'keep-alive') declares the connection fate: without
|
||||
it an HTTP/1.1 client assumes the connection persists and tries to reuse
|
||||
it for the next POST instead of dialing a new one (live card 2026-09-15)."""
|
||||
lines = ['HTTP/1.1 %d %s' % (status, reason)]
|
||||
sep = ':' if compact else ': '
|
||||
for name, value in headers.items():
|
||||
lines.append('%s%s%s' % (name, sep, value))
|
||||
if connection:
|
||||
lines.append('Connection%s%s' % (sep, connection))
|
||||
has_te = 'transfer-encoding' in [k.lower() for k in headers]
|
||||
if body and (chunked or has_te):
|
||||
if not has_te:
|
||||
lines.append('Transfer-Encoding: chunked')
|
||||
elif body and 'content-length' not in [k.lower() for k in headers]:
|
||||
lines.append('Content-Length%s%d' % (sep, len(body)))
|
||||
head = ('\r\n'.join(lines) + '\r\n\r\n').encode('latin-1')
|
||||
if not body:
|
||||
return head
|
||||
if not chunked:
|
||||
return head + body
|
||||
out = bytearray(head)
|
||||
for i in range(0, len(body), 100):
|
||||
piece = body[i:i + 100]
|
||||
out += ('%X\r\n' % len(piece)).encode('latin-1') + piece + b'\r\n'
|
||||
out += b'0\r\n\r\n'
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class PskTlsServer:
|
||||
"""PSK TLS listener speaking the GP remote administration HTTP dialog."""
|
||||
|
||||
def __init__(self, host, port, psk=None, identity=None, on_log=None,
|
||||
responder=None, timeout=10.0, chunked=False, chunk_size=0,
|
||||
compact_headers=False, tls_version='auto',
|
||||
cipher=None, keylog=None,
|
||||
conn_header=None, half_close=False, answer_delay=0.0,
|
||||
psk_map=None):
|
||||
# PSK lookup table: identity -> key. With an explicit psk_map a
|
||||
# handshake is accepted only for a listed identity; the legacy
|
||||
# single-key form (psk + optional identity pin, pin None = accept any
|
||||
# identity) remains for scripts and tests.
|
||||
self.wildcard_psk = None
|
||||
self.psk_map = {}
|
||||
if psk_map is not None:
|
||||
self.psk_map = {_norm_identity(k): bytes(v)
|
||||
for k, v in dict(psk_map).items() if v}
|
||||
elif psk is not None:
|
||||
pin = _norm_identity(identity)
|
||||
if pin is None:
|
||||
self.wildcard_psk = psk
|
||||
else:
|
||||
self.psk_map = {pin: bytes(psk)}
|
||||
self.psk = psk
|
||||
self.identity = _norm_identity(identity)
|
||||
self.on_log = on_log
|
||||
self.responder = responder or self._default_responder
|
||||
self.timeout = timeout
|
||||
self.chunked = chunked
|
||||
# chunk_size 0 = one record for the whole response
|
||||
self.chunk_size = int(chunk_size)
|
||||
self.compact_headers = compact_headers
|
||||
# TLS is permissive by default: 'auto' accepts TLS 1.0-1.2 and lets
|
||||
# OpenSSL pick the highest the card offers. The '1.0'/'1.1'/'1.2'
|
||||
# pins are debugging aids for a card that offers 1.2 but mishandles
|
||||
# it; no setting is needed for normal use.
|
||||
self.tls_version = (tls_version if tls_version == 'auto'
|
||||
or tls_version in TLS_VERSIONS else 'auto')
|
||||
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only
|
||||
# maps a specific suite to a usable SCP81 security level.
|
||||
self.cipher = cipher or None
|
||||
# Debug aid: write the TLS traffic secrets to this file
|
||||
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
|
||||
# decrypted (tshark etc). Contains key material - use a temp path.
|
||||
self.keylog = keylog or None
|
||||
# Connection header value: None/'none' = omit the header (implicit
|
||||
# HTTP/1.1 keep-alive); 'keep-alive' adds it explicitly. The server
|
||||
# never closes mid-session - only the 204 ends the dialog.
|
||||
self.conn_header = conn_header or None
|
||||
# TLS half-close after a script body. NOTE (live 2026-09-16):
|
||||
# CPython's SSLSocket.unwrap() poisons the session when the peer does
|
||||
# not answer with its own close_notify in time, so this cannot be
|
||||
# implemented with the stdlib ssl module; the flag is kept for the
|
||||
# option surface and for cards that answer promptly (the exception
|
||||
# path leaves the session unusable, so it is off by default).
|
||||
self.half_close = half_close
|
||||
# Wait before answering a request (cards may need their BIP SEND DATA
|
||||
# conversation to settle before they accept the response; 0 = answer
|
||||
# immediately).
|
||||
self.answer_delay = float(answer_delay or 0)
|
||||
self.identity_seen = None
|
||||
self.identity_matched = None
|
||||
self.version_seen = None
|
||||
self.cipher_seen = None
|
||||
self.stopped = False
|
||||
self.conns = []
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
# A quick Stop -> Start can race the previous listener's close (the
|
||||
# port stays busy for a moment); retry before giving up.
|
||||
last_error = None
|
||||
for _ in range(10):
|
||||
try:
|
||||
self.sock.bind((host, int(port)))
|
||||
last_error = None
|
||||
break
|
||||
except OSError as e:
|
||||
last_error = e
|
||||
time.sleep(0.3)
|
||||
if last_error is not None:
|
||||
self.sock.close()
|
||||
raise last_error
|
||||
self.sock.listen(4)
|
||||
self.host, self.port = self.sock.getsockname()[:2]
|
||||
self.ctx = self._make_context()
|
||||
if self.keylog:
|
||||
try:
|
||||
self.ctx.keylog_filename = self.keylog
|
||||
except (AttributeError, OSError):
|
||||
self.keylog = None
|
||||
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self.thread.start()
|
||||
self.log('tls-listener-start', host=self.host, port=self.port)
|
||||
|
||||
def log(self, kind, **fields):
|
||||
if self.on_log:
|
||||
try:
|
||||
self.on_log(kind, **fields)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_context(self):
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
if self.tls_version == 'auto':
|
||||
# Accept everything the cards speak; OpenSSL negotiates the
|
||||
# highest common version.
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
||||
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
else:
|
||||
ver = TLS_VERSIONS[self.tls_version]
|
||||
ctx.minimum_version = ver
|
||||
ctx.maximum_version = ver
|
||||
ciphers = self.cipher or PSK_CIPHERS
|
||||
if self.tls_version in ('auto', '1.0', '1.1'):
|
||||
# OpenSSL 3.x disables the legacy protocol versions by default.
|
||||
ciphers += ':@SECLEVEL=0'
|
||||
ctx.set_ciphers(ciphers)
|
||||
# Prefer our (AES-first) order over the card's NULL-suite-first list.
|
||||
ctx.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
|
||||
ctx.options |= OP_NO_ENCRYPT_THEN_MAC
|
||||
# No TLS session resumption: the live card aborts with
|
||||
# SSLV3_ALERT_UNEXPECTED_MESSAGE on the post-handshake
|
||||
# NewSessionTicket record (verified live 2026-09-15).
|
||||
ctx.options |= ssl.OP_NO_TICKET
|
||||
if not hasattr(ctx, 'set_psk_server_callback'):
|
||||
# ssl's TLS-PSK callbacks exist only since Python 3.13.
|
||||
raise RuntimeError(
|
||||
'TLS-PSK requires Python 3.13+ (ssl.SSLContext.set_psk_server_callback '
|
||||
'is missing in %s)' % sys.version.split()[0])
|
||||
ctx.set_psk_server_callback(self._psk_cb)
|
||||
return ctx
|
||||
|
||||
def _psk_cb(self, identity):
|
||||
"""OpenSSL asks for the key of the identity the client sent.
|
||||
|
||||
The identity is looked up in the configured table (identity -> key);
|
||||
without a match the handshake fails on the Finished MAC check with a
|
||||
dummy key, and the attempt is logged as 'tls-psk-unknown'."""
|
||||
ident = _norm_identity(identity)
|
||||
self.identity_seen = ident
|
||||
key = self.psk_map.get(ident) if ident is not None else None
|
||||
if key is None:
|
||||
# Legacy single-key mode: no identity pin accepts any identity.
|
||||
key = self.wildcard_psk
|
||||
self.identity_matched = key is not None
|
||||
if key is None:
|
||||
self.log('tls-psk-unknown', identity=ident)
|
||||
return b'\x00' * 16
|
||||
return key
|
||||
|
||||
@property
|
||||
def psk_identities(self):
|
||||
"""Identities the listener looks up (keys are never exposed)."""
|
||||
return sorted(self.psk_map)
|
||||
|
||||
def set_psk_map(self, psk_map):
|
||||
"""Replace the identity -> key table of a running listener."""
|
||||
self.psk_map = {_norm_identity(k): bytes(v)
|
||||
for k, v in dict(psk_map).items() if v}
|
||||
self.wildcard_psk = None
|
||||
return self.psk_identities
|
||||
|
||||
@staticmethod
|
||||
def _default_responder(method, target, headers, body):
|
||||
"""No script configured: close the administration session (4.4.2)."""
|
||||
return 204, {'X-Admin-Protocol': GP_PROTOCOL}, b''
|
||||
|
||||
def _accept_loop(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
self.sock.settimeout(0.2)
|
||||
conn, addr = self.sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
self.conns.append(conn)
|
||||
peer = '%s:%d' % addr[:2]
|
||||
threading.Thread(target=self._conn_loop, args=(conn, peer),
|
||||
daemon=True).start()
|
||||
|
||||
def _read_request(self, tls):
|
||||
buf = b''
|
||||
while b'\r\n\r\n' not in buf:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
if len(buf) > MAX_HEAD:
|
||||
raise ValueError('request head too large')
|
||||
head, _, rest = buf.partition(b'\r\n\r\n')
|
||||
method, target, headers = parse_http_request(head + b'\r\n\r\n')
|
||||
body = rest
|
||||
if 'content-length' in headers:
|
||||
want = int(headers['content-length'])
|
||||
while len(body) < want:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = body[:want]
|
||||
elif headers.get('transfer-encoding', '').lower() == 'chunked':
|
||||
while not body.endswith(b'0\r\n\r\n'):
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
body = decode_chunked(body)
|
||||
return method, target, headers, body
|
||||
|
||||
def _conn_loop(self, conn, peer):
|
||||
tls = None
|
||||
handshake_done = False
|
||||
try:
|
||||
tls = self.ctx.wrap_socket(conn, server_side=True)
|
||||
handshake_done = True
|
||||
self.version_seen = tls.version()
|
||||
self.cipher_seen = (tls.cipher() or (None,))[0]
|
||||
self.log('tls-handshake', peer=peer, cipher=self.cipher_seen,
|
||||
version=self.version_seen, identity=self.identity_seen,
|
||||
psk_match=self.identity_matched)
|
||||
while not self.stopped:
|
||||
req = self._read_request(tls)
|
||||
if req is None:
|
||||
break
|
||||
method, target, headers, body = req
|
||||
if self.answer_delay > 0:
|
||||
time.sleep(self.answer_delay)
|
||||
self.log('tls-request', peer=peer, method=method, uri=target,
|
||||
headers=headers,
|
||||
agent=headers.get('x-admin-from'),
|
||||
protocol=headers.get('x-admin-protocol'),
|
||||
script_status=headers.get('x-admin-script-status'),
|
||||
resume=headers.get('x-admin-resume'),
|
||||
content_type=headers.get('content-type'),
|
||||
bytes=len(body), body_hex=body.hex().upper()[:2000] or None)
|
||||
status, resp_headers, resp_body = self.responder(
|
||||
method, target, headers, body)
|
||||
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
|
||||
conn_hdr = None if self.conn_header in (None, 'none') else self.conn_header
|
||||
response = build_http_response(
|
||||
status, reason, resp_headers, resp_body,
|
||||
chunked=self.chunked, compact=self.compact_headers,
|
||||
connection=conn_hdr)
|
||||
# The card's HTTP client reads its response record-by-record:
|
||||
# the whole response must arrive in ONE TLS record (chunk_size
|
||||
# 0), otherwise a split head stalls it and a head-only record
|
||||
# followed by the body draws an unexpected_message alert. When
|
||||
# a chunk_size is given, the head goes in one record and the
|
||||
# body in pieces of that size.
|
||||
if self.chunk_size <= 0:
|
||||
tls.sendall(response)
|
||||
else:
|
||||
head, sep, rest = response.partition(b'\r\n\r\n')
|
||||
tls.sendall(head + sep if sep else head)
|
||||
for off in range(0, len(rest), self.chunk_size):
|
||||
tls.sendall(rest[off:off + self.chunk_size])
|
||||
self.log('tls-response', peer=peer, status=status,
|
||||
bytes=len(resp_body), chunked=self.chunked,
|
||||
response_hex=response.hex().upper()[:600],
|
||||
body_hex=resp_body.hex().upper()[:2000] or None)
|
||||
# Only the end of the dialog closes the connection: 204 (or
|
||||
# an empty body) ends the session; every other response
|
||||
# leaves the TLS connection open for the card's next POST.
|
||||
# Reusing it - or dialing a fresh one - is the card's call
|
||||
# (GP Am. B 4.3.1: the SD manages connection establishment).
|
||||
if status == 204 or not resp_body:
|
||||
# Clean TLS shutdown with the response still in the BIP
|
||||
# buffer: the card fetches the 204 and the close_notify
|
||||
# together, then the FIN. A bare close here makes the
|
||||
# card abort the session with a fatal alert.
|
||||
plain = None
|
||||
try:
|
||||
tls.settimeout(2.0)
|
||||
plain = tls.unwrap()
|
||||
tls = None
|
||||
except Exception:
|
||||
plain = None
|
||||
if plain is not None:
|
||||
try:
|
||||
plain.close()
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
except ssl.SSLError as e:
|
||||
if handshake_done:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
else:
|
||||
# No shared cipher / unsupported protocol version / card
|
||||
# alert: keep the handshake reason distinguishable from
|
||||
# post-handshake record errors.
|
||||
self.log('tls-handshake-failed', peer=peer, error=str(e))
|
||||
except (OSError, ValueError) as e:
|
||||
self.log('tls-error', peer=peer, error=str(e))
|
||||
finally:
|
||||
if tls is not None:
|
||||
try:
|
||||
tls.close()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.log('tls-close', peer=peer)
|
||||
if conn in self.conns:
|
||||
self.conns.remove(conn)
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
self.log('tls-listener-stop', host=self.host, port=self.port)
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for conn in list(self.conns):
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.conns = []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user