Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393b1098ce | ||
|
|
295f5553fc | ||
|
|
2ecf7bb45c | ||
|
|
a269f8bd2e | ||
|
|
0231df905c |
No files matched your search
@@ -9,6 +9,44 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.38.1] — 2026-10-25
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Equip/initialization reads no longer select the file twice.** pySim's
|
||||||
|
`scc.read_binary`/`scc.read_record` call `select_path(ef)` first, but the
|
||||||
|
ICCID, network-state, EF.ARR and EF.PBR readers had already made a
|
||||||
|
parent-scoped selection and held its FCP. A new `_read_selected_ef()` reads
|
||||||
|
the selected file directly — READ BINARY chunked at `max_cmd_len`, READ
|
||||||
|
RECORD at the FCP record length — removing one SELECT per file read and one
|
||||||
|
per record (~106 files + 94 records on the bench card: ~0.7 s of the ~3.4 s
|
||||||
|
traced startup init). The pySim helpers stay as the fallback when the FCP
|
||||||
|
lacks the size/record length.
|
||||||
|
- The startup TERMINAL PROFILE drain uses the chain's final SW, so an idle
|
||||||
|
chain no longer costs one extra STATUS poll (~128 ms).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A PC/SC access denial is reported instead of a silent serial fallback.**
|
||||||
|
When no reader was named and `readers()` failed (e.g. `SCARD_E_NO_ACCESS` —
|
||||||
|
the user may not use `pcscd`), the error was swallowed and pySim fell back to
|
||||||
|
the serial transport, failing on `/dev/ttyUSB0` while the server still printed
|
||||||
|
"listening". The startup now probes PC/SC and reports **`PC/SC access
|
||||||
|
denied`** (naming the pcsc-lite PolicyKit actions and the
|
||||||
|
`99-pcscd-allow.rules` rule — a world-writable `pcscd.comm` does not bypass
|
||||||
|
polkit's active-session policy — plus the `pcsc_scan` cross-check),
|
||||||
|
**`pcscd is not running`** or **`no PC/SC reader found`**, then exits 2;
|
||||||
|
`start.sh` runs the same access pre-flight before launching. An
|
||||||
|
explicit `-d <device>` keeps the serial path, and a reader present but no card
|
||||||
|
is still a normal cardless start.
|
||||||
|
- **`setup.sh`/`setup.bat` recover from an incomplete `.venv`.** A failed venv
|
||||||
|
creation (e.g. the Debian/Ubuntu `python3-venv` package missing) left the
|
||||||
|
directory behind; the next run saw `.venv` and skipped creation, then tried to
|
||||||
|
activate a venv without `activate`/`pip`. The scripts now validate
|
||||||
|
`bin/activate`/`bin/python`/`bin/pip` (`Scripts\…` on Windows), remove an
|
||||||
|
incomplete directory and create it again; a creation failure removes the
|
||||||
|
partial directory and points at the venv package. The READMEs note it.
|
||||||
|
|
||||||
## [3.38.0] — 2026-10-24
|
## [3.38.0] — 2026-10-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **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).
|
- **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).
|
||||||
|
- **Python venv support** — `setup.sh` creates `.venv`; if that fails with `ensurepip is not available` (Debian/Ubuntu ship the venv module separately), install it (`sudo apt install python3-venv`) and run `./setup.sh` again. An incomplete `.venv` left by the failed run is removed automatically.
|
||||||
- **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).
|
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -755,11 +756,11 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
|
|||||||
|
|
||||||
| Script | What it does |
|
| Script | What it does |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning or after unzipping the release package. |
|
| `setup.sh` / `setup.bat` | Creates `.venv/` (or repairs an incomplete one), 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`). |
|
| `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`. |
|
| `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` 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.
|
`start.sh` checks PC/SC access before starting: if the user may not use `pcscd` (or the service is not running) it prints the cause with the group/polkit guidance and exits — the server **no longer falls back to a serial reader silently**. With access, the server auto-detects the first PC/SC reader; `start.bat` always passes `-p 0` (PC/SC is built into Windows). An explicit serial/FTDI reader is still possible with `-d <device>` (the pre-flight check is skipped). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no PC/SC reader is found the server reports it and exits — connect the reader and restart (a reader present but no card is still a normal cardless start).
|
||||||
|
|
||||||
### Manual installation (Linux/macOS)
|
### Manual installation (Linux/macOS)
|
||||||
|
|
||||||
@@ -799,8 +800,21 @@ its MSVC build chain) is not pulled in.
|
|||||||
|
|
||||||
### Troubleshooting
|
### 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`.
|
- **"Failed to establish context: Access denied"** — the user may not use `pcscd`. On a headless/SSH box the socket is often world-writable (`srw-rw-rw- root root`) yet access is still denied: pcscd checks **PolicyKit**, whose default policy allows only *active local* sessions (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Allow the user with `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (replace `YOUR_USER`; any `*.rules` file name works):
|
||||||
- **"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.
|
|
||||||
|
```js
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
then `sudo systemctl restart polkit` (or log out/in). Start the service if needed (`sudo systemctl enable --now pcscd`); `start.sh` and the server report this and exit instead of falling back to a serial reader, and `pcsc_scan` shows the same error until the rule is active. If your `pcscd` was built without PolicyKit and the socket is group-owned, adding the user to that group works instead.
|
||||||
|
- **"device file /dev/ttyUSB0 does not exist"** — only with an explicit `-d <device>`: the device is not connected (or the path is wrong). Without `-d` the server no longer uses the serial transport.
|
||||||
|
- **"no PC/SC reader found"** — connect a reader (`pcsc_scan` lists them) and restart; the server exits instead of starting 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.
|
- **"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 card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
- **"Card was removed" errors / auto-equip fails after a card swap** — a card insertion marks the PC/SC link for a rebuild (so the **first** attempt already uses a fresh link — on Windows the old one cannot connect after a swap), the auto-equip retries up to 3 times, and **every failed attempt rebuilds the transport** before the next one. A card removed while the server initializes at startup leaves it cardless with one line (no traceback), and the next insertion equips normally. The swallowed init error (with its traceback) is now printed as `AUTO-EQUIP: init output:`, and a card that is present but produced no card is a failure, not a silent "card gone". The watchdog re-arms with a doubling delay (5 s up to 60 s) while a card is present; a removed card clears the card state completely, so handlers answer "no card" instead of transmitting over the dead card.
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -732,6 +732,7 @@ PWA проверяет версию сервера при подключении
|
|||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
- **Python 3.9+** с `pip`, и **Git** — приложение работает на 3.9+, но **SCP81 TLS-PSK-слушателю нужен Python 3.13+** (PSK-callback'и `ssl`).
|
||||||
|
- **Поддержка venv в Python** — `setup.sh` создаёт `.venv`; если создание завершается с `ensurepip is not available` (в Debian/Ubuntu модуль venv поставляется отдельно), установите его (`sudo apt install python3-venv`) и запустите `./setup.sh` снова. Неполный `.venv`, оставшийся от неудачного запуска, удаляется автоматически.
|
||||||
- **Смарт-картридер** (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`).
|
- **Смарт-картридер** (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 сознательно не ставится.
|
- **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 сознательно не ставится.
|
||||||
|
|
||||||
@@ -739,11 +740,11 @@ PWA проверяет версию сервера при подключении
|
|||||||
|
|
||||||
| Скрипт | Назначение |
|
| Скрипт | Назначение |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
| `setup.sh` / `setup.bat` | Создаёт `.venv/` (или исправляет неполный), ставит pysim и сервер. Запускать один раз после клонирования или распаковки релизного пакета. |
|
||||||
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
| `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). |
|
||||||
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
| `update.sh` / `update.bat` | Обновляет пакетную установку на месте: скачивает последний релизный пакет, заменяет файлы приложения и переустанавливает приложение в существующий `.venv` (pysim сохраняется). `--check`, `--version X.Y.Z`, `--zip FILE`, `--no-pip`, `--force`. |
|
||||||
|
|
||||||
`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**.
|
`start.sh` перед запуском проверяет доступ к PC/SC: если пользователь не может использовать `pcscd` (или служба не запущена), он печатает причину и рекомендации (группа/polkit) и завершается — сервер **больше не откатывается на последовательный ридер молча**. При наличии доступа сервер сам находит первый PC/SC-ридер; `start.bat` всегда передаёт `-p 0` (в Windows PC/SC встроен). Явный serial/FTDI-ридер по-прежнему возможен через `-d <устройство>` (проверка пропускается). Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Если PC/SC-ридер не найден, сервер сообщает об этом и завершается — подключите ридер и перезапустите (ридер без карты — по-прежнему нормальный старт без карты).
|
||||||
|
|
||||||
### Ручная установка (Linux/macOS)
|
### Ручная установка (Linux/macOS)
|
||||||
|
|
||||||
@@ -783,8 +784,21 @@ pysim-simple-server --http-port 8080
|
|||||||
|
|
||||||
### Устранение неполадок
|
### Устранение неполадок
|
||||||
|
|
||||||
- **"Failed to establish context: Access denied"** — `pcscd` не запущен или нет прав: `sudo systemctl enable --now pcscd && sudo usermod -a -G pcscd $USER`.
|
- **"Failed to establish context: Access denied"** — у пользователя нет прав на `pcscd`. На headless/SSH-системе сокет часто доступен всем (`srw-rw-rw- root root`), но доступ всё равно запрещён: pcscd проверяет **PolicyKit**, политика по умолчанию разрешает только *активные локальные* сессии (`allow_active=yes`, `allow_inactive=no`, `allow_any=no`). Разрешите пользователя в `/etc/polkit-1/rules.d/99-pcscd-allow.rules` (замените `YOUR_USER`; имя файла `*.rules` может быть любым):
|
||||||
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
|
|
||||||
|
```js
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "YOUR_USER") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
затем `sudo systemctl restart polkit` (или выйдите и войдите). При необходимости запустите службу (`sudo systemctl enable --now pcscd`); `start.sh` и сервер сообщают об этом и завершаются вместо отката на serial-ридер, а `pcsc_scan` показывает ту же ошибку, пока правило не начнёт действовать. Если `pcscd` собран без PolicyKit и сокет принадлежит отдельной группе, вместо этого можно добавить пользователя в неё.
|
||||||
|
- **"device file /dev/ttyUSB0 does not exist"** — только при явном `-d <устройство>`: устройство не подключено (или путь неверен). Без `-d` сервер больше не использует последовательный транспорт.
|
||||||
|
- **"no PC/SC reader found"** — подключите ридер (`pcsc_scan` покажет список) и перезапустите; сервер завершается, а не стартует без ридера.
|
||||||
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
- **"Service not available" (0x8010001D) / ридер пропал после перезапуска `pcscd`** — сервер восстанавливается сам: сторож перезапускает монитор присутствия pyscard, а следующий equip (автоматически после повторной вставки карты или кнопкой **Equip**) пересоздаёт транспорт PC/SC (сначала освобождая старый линк). Перезапуск сервера не нужен.
|
||||||
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1959,7 +1959,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.38.0';
|
const SIMPLE_VERSION = '3.38.1';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== view history (the browser Back/Forward buttons) =====
|
// ===== view history (the browser Back/Forward buttons) =====
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v481';
|
const CACHE = 'simple-v482';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.38.0"
|
version = "3.38.1"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
license = { text = "GPL-2.0-or-later" }
|
license = { text = "GPL-2.0-or-later" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+135
-18
@@ -7,7 +7,7 @@ import traceback
|
|||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
from pySim.card_handler import CardHandler
|
from pySim.card_handler import CardHandler
|
||||||
from pySim.commands import SimCardCommands
|
from pySim.commands import SimCardCommands
|
||||||
from pySim.exceptions import NoCardError
|
from pySim.exceptions import NoCardError, ReaderError
|
||||||
from pySim.log import PySimLogger
|
from pySim.log import PySimLogger
|
||||||
from pySim.cards import UiccCardBase
|
from pySim.cards import UiccCardBase
|
||||||
|
|
||||||
@@ -23,6 +23,115 @@ from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION,
|
|||||||
|
|
||||||
_server_start = 0
|
_server_start = 0
|
||||||
|
|
||||||
|
# PC/SC hresults (pyscard's exceptions carry them; the messages are often
|
||||||
|
# empty, so the hresult is the reliable signal).
|
||||||
|
_PCSC_E_UNKNOWN_READER = 0x80100009
|
||||||
|
_PCSC_E_NO_SERVICE = 0x8010001D
|
||||||
|
_PCSC_E_SERVICE_STOPPED = 0x8010001E
|
||||||
|
_PCSC_E_NO_ACCESS = 0x80100027
|
||||||
|
_PCSC_E_NO_READERS_AVAILABLE = 0x8010002E
|
||||||
|
|
||||||
|
|
||||||
|
class PcscUnavailable(RuntimeError):
|
||||||
|
"""The PC/SC stack cannot be used (kind: access/service/reader)."""
|
||||||
|
|
||||||
|
def __init__(self, kind, cause=None):
|
||||||
|
super().__init__(kind)
|
||||||
|
self.kind = kind
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
|
def _pcsc_error_kind(exc):
|
||||||
|
"""Classify a pyscard failure: 'access', 'service', 'reader' or None.
|
||||||
|
|
||||||
|
``SCARD_E_NO_ACCESS`` is the "Access denied" a user without permission to
|
||||||
|
pcscd gets; the message is a fallback because some pyscard paths raise a
|
||||||
|
CardConnectionException with an empty message and only the hresult."""
|
||||||
|
hr = getattr(exc, 'hresult', None)
|
||||||
|
msg = str(exc or '').lower()
|
||||||
|
if hr == _PCSC_E_NO_ACCESS or 'access denied' in msg or 'access is denied' in msg:
|
||||||
|
return 'access'
|
||||||
|
if hr in (_PCSC_E_NO_SERVICE, _PCSC_E_SERVICE_STOPPED) or \
|
||||||
|
'resource manager is not running' in msg or 'service not available' in msg:
|
||||||
|
return 'service'
|
||||||
|
if hr in (_PCSC_E_UNKNOWN_READER, _PCSC_E_NO_READERS_AVAILABLE) or \
|
||||||
|
'no smart card readers' in msg:
|
||||||
|
return 'reader'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _report_pcsc_unavailable(kind, exc=None):
|
||||||
|
"""Print an actionable reason instead of starting a readerless server."""
|
||||||
|
if kind == 'access':
|
||||||
|
user = os.environ.get('USER') or os.environ.get('LOGNAME') or '$USER'
|
||||||
|
sys.stderr.write('INIT: PC/SC access denied - the current user may not use pcscd.\n')
|
||||||
|
sys.stderr.write(' pcscd gates access through PolicyKit: the default policy allows\n')
|
||||||
|
sys.stderr.write(' only *active local* sessions, so an SSH/headless session is denied\n')
|
||||||
|
sys.stderr.write(' even when /run/pcscd/pcscd.comm is world-writable (0666).\n')
|
||||||
|
sys.stderr.write(' Allow the user in /etc/polkit-1/rules.d/99-pcscd-allow.rules:\n')
|
||||||
|
sys.stderr.write(' polkit.addRule(function(a, s) {\n')
|
||||||
|
sys.stderr.write(' if ((a.id == "org.debian.pcsc-lite.access_pcsc" ||\n')
|
||||||
|
sys.stderr.write(' a.id == "org.debian.pcsc-lite.access_card") &&\n')
|
||||||
|
sys.stderr.write(' s.user == "%s") return polkit.Result.YES;\n' % user)
|
||||||
|
sys.stderr.write(' });\n')
|
||||||
|
sys.stderr.write(' then restart polkit (sudo systemctl restart polkit) or log out/in;\n')
|
||||||
|
sys.stderr.write(' `pcsc_scan` shows the same "SCardEstablishContext: Access denied"\n')
|
||||||
|
sys.stderr.write(' error until the rule is active.\n')
|
||||||
|
elif kind == 'service':
|
||||||
|
sys.stderr.write('INIT: pcscd is not running or not reachable.\n')
|
||||||
|
sys.stderr.write(' Start it (sudo systemctl start pcscd) and restart the server.\n')
|
||||||
|
else:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader found.\n')
|
||||||
|
sys.stderr.write(' Connect a reader (pcsc_scan lists them) and restart the server.\n')
|
||||||
|
if exc is not None:
|
||||||
|
hr = getattr(exc, 'hresult', None)
|
||||||
|
detail = str(exc) or type(exc).__name__
|
||||||
|
if hr not in (None, -1):
|
||||||
|
detail += ' (hresult=0x%08X)' % hr
|
||||||
|
sys.stderr.write(' (%s)\n' % detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_pcsc_reader(retries=3, sleep_fn=time.sleep, readers_fn=None):
|
||||||
|
"""Return the first PC/SC reader name or raise PcscUnavailable.
|
||||||
|
|
||||||
|
``SCARD_E_NO_ACCESS``/service failures are raised immediately (retrying
|
||||||
|
cannot fix permissions or a stopped service); an empty list is retried
|
||||||
|
(USB enumeration) and reported as 'reader' after the last attempt."""
|
||||||
|
if readers_fn is None:
|
||||||
|
from smartcard.System import readers as readers_fn
|
||||||
|
last = None
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
found = readers_fn()
|
||||||
|
except Exception as exc:
|
||||||
|
last = exc
|
||||||
|
kind = _pcsc_error_kind(exc)
|
||||||
|
if kind in ('access', 'service'):
|
||||||
|
raise PcscUnavailable(kind, exc) from exc
|
||||||
|
else:
|
||||||
|
if found:
|
||||||
|
return str(found[0])
|
||||||
|
last = None
|
||||||
|
if attempt + 1 < retries:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader yet, retrying in 2s...\n')
|
||||||
|
sleep_fn(2)
|
||||||
|
raise PcscUnavailable('reader', last)
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_arg_given(argv=None):
|
||||||
|
"""True when the operator explicitly chose the serial reader (-d/--device).
|
||||||
|
|
||||||
|
The default (-d is /dev/ttyUSB0 in pySim's parser) must not masquerade as
|
||||||
|
an explicit choice: only a flag on the command line counts."""
|
||||||
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
|
for arg in argv:
|
||||||
|
if arg in ('-d', '--device') or arg.startswith('--device='):
|
||||||
|
return True
|
||||||
|
if arg.startswith('-d') and not arg.startswith('--') and len(arg) > 2:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _log_stdout(msg):
|
def _log_stdout(msg):
|
||||||
elapsed = time.time() - _server_start
|
elapsed = time.time() - _server_start
|
||||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||||
@@ -164,22 +273,18 @@ def main():
|
|||||||
rs = None
|
rs = None
|
||||||
sim_menu = None
|
sim_menu = None
|
||||||
event_list = None
|
event_list = None
|
||||||
# Auto-detect PC/SC reader if none was explicitly specified.
|
# Auto-detect the PC/SC reader unless one was named explicitly or the
|
||||||
# Handles late pcscd startup and USB enumeration delays.
|
# operator chose the serial transport (-d). A PC/SC access denial or a
|
||||||
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
# missing service must be reported - silently falling back to the serial
|
||||||
|
# reader left the server "listening" without a usable card link (v3.38.1).
|
||||||
|
if opts.pcsc_dev is None and opts.pcsc_regex is None and not _serial_arg_given():
|
||||||
try:
|
try:
|
||||||
from smartcard.System import readers
|
reader = _probe_pcsc_reader()
|
||||||
for attempt in range(3):
|
except PcscUnavailable as e:
|
||||||
r = readers()
|
_report_pcsc_unavailable(e.kind, e.cause)
|
||||||
if r:
|
return 2
|
||||||
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % reader)
|
||||||
opts.pcsc_dev = 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:
|
try:
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@@ -223,7 +328,15 @@ def main():
|
|||||||
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
||||||
scc = card._scc
|
scc = card._scc
|
||||||
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
||||||
except Exception:
|
except ReaderError as e:
|
||||||
|
sys.stderr.write('INIT: no PC/SC reader found: %s\n' % e)
|
||||||
|
sys.stderr.write(' Connect the reader (pcsc_scan lists them) and restart the server.\n')
|
||||||
|
return 2
|
||||||
|
except Exception as e:
|
||||||
|
kind = _pcsc_error_kind(e)
|
||||||
|
if kind:
|
||||||
|
_report_pcsc_unavailable(kind, e)
|
||||||
|
return 2
|
||||||
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
ch = CardHandler(sl) if sl else None
|
ch = CardHandler(sl) if sl else None
|
||||||
@@ -293,7 +406,11 @@ def main():
|
|||||||
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
||||||
if not st_sw.startswith('91'):
|
if not st_sw.startswith('91'):
|
||||||
break
|
break
|
||||||
_handle_proactive_chain(scc, st_sw)
|
chain_sw = _handle_proactive_chain(scc, st_sw)
|
||||||
|
# the chain's trailing STATUS already proved the card idle;
|
||||||
|
# polling once more only costs an APDU (v3.38.1)
|
||||||
|
if chain_sw and not chain_sw.startswith('91'):
|
||||||
|
break
|
||||||
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_pcsc_error(e):
|
if _is_pcsc_error(e):
|
||||||
@@ -434,4 +551,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
sys.exit(main())
|
||||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.38.0'
|
VERSION = '3.38.1'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -483,6 +483,45 @@ def _decode_iccid(data_hex):
|
|||||||
return digits
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
def _read_selected_ef(lchan, kind, rec=None, length=None, offset=0):
|
||||||
|
"""Read the currently selected EF **without selecting it again**.
|
||||||
|
|
||||||
|
pySim's ``scc.read_binary``/``scc.read_record`` call ``select_path(ef)``
|
||||||
|
first. Our callers have already made a parent-scoped selection and hold
|
||||||
|
its FCP, so that second SELECT is redundant (one per file read, one per
|
||||||
|
record). A transparent file is read in ``max_cmd_len`` chunks from
|
||||||
|
``offset`` (the whole file when ``length`` is None, using the FCP file
|
||||||
|
size); a record file reads record ``rec`` (1-based) at the FCP's record
|
||||||
|
length. Returns ``(data, sw)`` like the pySim helpers; falls back to them
|
||||||
|
when the FCP lacks the needed value.
|
||||||
|
|
||||||
|
The caller must select the file first (``_select_path``/``_select_with_parent``)
|
||||||
|
and must not interleave another selection before the read."""
|
||||||
|
scc = getattr(lchan, 'scc', None)
|
||||||
|
if kind == 'record':
|
||||||
|
rec_len = lchan.selected_file_record_len()
|
||||||
|
if scc is not None and rec_len:
|
||||||
|
pdu = scc.cla_byte + 'b2%02x04%02x' % (int(rec), rec_len)
|
||||||
|
return scc.send_apdu_checksw(pdu)
|
||||||
|
return lchan.read_record(rec)
|
||||||
|
size = lchan.selected_file_size()
|
||||||
|
if scc is None or size is None:
|
||||||
|
return lchan.read_binary(length, offset)
|
||||||
|
if length is None:
|
||||||
|
length = size - offset
|
||||||
|
if length <= 0:
|
||||||
|
return '', '9000'
|
||||||
|
total, sw = '', ''
|
||||||
|
chunk = 0
|
||||||
|
while chunk < length:
|
||||||
|
n = min(scc.max_cmd_len, length - chunk)
|
||||||
|
pdu = scc.cla_byte + 'b0%04x%02x' % (offset + chunk, n)
|
||||||
|
data, sw = scc.send_apdu_checksw(pdu)
|
||||||
|
total += data or ''
|
||||||
|
chunk += n
|
||||||
|
return total, sw
|
||||||
|
|
||||||
|
|
||||||
def _read_iccid(app):
|
def _read_iccid(app):
|
||||||
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
|
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
|
||||||
|
|
||||||
@@ -495,7 +534,7 @@ def _read_iccid(app):
|
|||||||
cleanup = None
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
|
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
|
||||||
data, _sw = lchan.read_binary()
|
data, _sw = _read_selected_ef(lchan, 'transparent')
|
||||||
return _decode_iccid(data)
|
return _decode_iccid(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
@@ -536,7 +575,7 @@ def _read_arr(app):
|
|||||||
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
|
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
|
||||||
records = []
|
records = []
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append((data or '').upper())
|
records.append((data or '').upper())
|
||||||
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
||||||
@@ -602,7 +641,7 @@ def _read_pbr(app):
|
|||||||
n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT)
|
n = min(lchan.selected_file_num_of_rec() or 1, PBR_RECORD_LIMIT)
|
||||||
records = []
|
records = []
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append((data or '').upper())
|
records.append((data or '').upper())
|
||||||
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
|
||||||
@@ -886,14 +925,14 @@ def _netstate_read(app, keys=None):
|
|||||||
records = []
|
records = []
|
||||||
n = lchan.selected_file_num_of_rec() or 1
|
n = lchan.selected_file_num_of_rec() or 1
|
||||||
for i in range(1, n + 1):
|
for i in range(1, n + 1):
|
||||||
rv = lchan.read_record(i)
|
rv = _read_selected_ef(lchan, 'record', i)
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
records.append({'num': i,
|
records.append({'num': i,
|
||||||
'data': (data or '').upper()})
|
'data': (data or '').upper()})
|
||||||
entry.update({'present': True, 'kind': 'record',
|
entry.update({'present': True, 'kind': 'record',
|
||||||
'records': records})
|
'records': records})
|
||||||
else:
|
else:
|
||||||
rv = lchan.read_binary()
|
rv = _read_selected_ef(lchan, 'transparent')
|
||||||
data = rv[0] if isinstance(rv, tuple) else rv
|
data = rv[0] if isinstance(rv, tuple) else rv
|
||||||
entry.update({'present': True, 'kind': 'transparent',
|
entry.update({'present': True, 'kind': 'transparent',
|
||||||
'data': (data or '').upper()})
|
'data': (data or '').upper()})
|
||||||
|
|||||||
@@ -34,10 +34,35 @@ echo Found Git
|
|||||||
git --version
|
git --version
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM Create venv
|
REM Create the venv. A failed creation leaves a partial .venv behind: remove
|
||||||
|
REM it so the next run starts over (v3.38.1).
|
||||||
|
set "VENV_OK="
|
||||||
|
if exist "%VENV_DIR%" (
|
||||||
|
set "VENV_OK=1"
|
||||||
|
if not exist "%VENV_DIR%\Scripts\activate.bat" set "VENV_OK="
|
||||||
|
if not exist "%VENV_DIR%\Scripts\python.exe" set "VENV_OK="
|
||||||
|
if not exist "%VENV_DIR%\Scripts\pip.exe" set "VENV_OK="
|
||||||
|
if not defined VENV_OK (
|
||||||
|
echo Removing an incomplete virtual environment (a previous setup did not finish)...
|
||||||
|
rmdir /s /q "%VENV_DIR%"
|
||||||
|
)
|
||||||
|
)
|
||||||
if not exist "%VENV_DIR%" (
|
if not exist "%VENV_DIR%" (
|
||||||
echo Creating virtual environment...
|
echo Creating virtual environment...
|
||||||
python -m venv "%VENV_DIR%"
|
python -m venv "%VENV_DIR%"
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
if exist "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
|
||||||
|
echo Error: could not create the virtual environment.
|
||||||
|
echo Make sure the Python installation includes venv and pip.
|
||||||
|
echo Run the Python installer, choose Modify, enable pip/venv, then run setup.bat again.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exist "%VENV_DIR%\Scripts\activate.bat" (
|
||||||
|
echo Error: the virtual environment has no Scripts\activate.bat.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
)
|
)
|
||||||
call "%VENV_DIR%\Scripts\activate.bat"
|
call "%VENV_DIR%\Scripts\activate.bat"
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,24 @@ command -v git >/dev/null 2>&1 || { echo "Error: Git is required but not found.
|
|||||||
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
echo "Found Python $PY_VER"
|
echo "Found Python $PY_VER"
|
||||||
|
|
||||||
# Create venv
|
# Create the venv. A failed creation (missing venv/ensurepip) leaves a partial
|
||||||
|
# .venv behind: validate it and remove it so the next run starts over (v3.38.1).
|
||||||
|
if [ -d "$VENV_DIR" ] && { [ ! -f "$VENV_DIR/bin/activate" ] || [ ! -x "$VENV_DIR/bin/python" ] || [ ! -x "$VENV_DIR/bin/pip" ]; }; then
|
||||||
|
echo "Removing an incomplete virtual environment (a previous setup did not finish)..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
echo "Creating virtual environment..."
|
echo "Creating virtual environment..."
|
||||||
python3 -m venv "$VENV_DIR"
|
if ! python3 -m venv "$VENV_DIR"; then
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
echo "Error: could not create the virtual environment."
|
||||||
|
echo " The Python venv/ensurepip component is missing or broken."
|
||||||
|
echo " Debian/Ubuntu: sudo apt install python3-venv"
|
||||||
|
echo " Fedora: sudo dnf install python3-libs"
|
||||||
|
echo " Arch: venv and ensurepip are part of python"
|
||||||
|
echo " Install it and run ./setup.sh again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
PIP="$VENV_DIR/bin/pip"
|
PIP="$VENV_DIR/bin/pip"
|
||||||
|
|||||||
@@ -16,14 +16,53 @@ LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
|
|||||||
exec > >(tee "$LOG_FILE") 2>&1
|
exec > >(tee "$LOG_FILE") 2>&1
|
||||||
export PYTHONUNBUFFERED=1 # keep the log live: a piped stdout is block-buffered
|
export PYTHONUNBUFFERED=1 # keep the log live: a piped stdout is block-buffered
|
||||||
|
|
||||||
# Auto-detect reader
|
# PC/SC pre-flight (v3.38.1): a user without access to pcscd must see the real
|
||||||
|
# cause - the server no longer falls back to /dev/ttyUSB0 silently. An
|
||||||
|
# explicit serial reader (-d/--device) keeps the serial path.
|
||||||
|
SERIAL=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-d|--device|--device=*|-d*) SERIAL=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
READER_ARGS=""
|
READER_ARGS=""
|
||||||
if command -v pcscd > /dev/null 2>&1; then
|
if [ "$SERIAL" -eq 0 ]; then
|
||||||
# Give pcscd a moment if it's not running yet (USB enumeration delay)
|
PYTHON_BIN="$VENV_DIR/bin/python"
|
||||||
if ! pgrep -x pcscd > /dev/null 2>&1 && ! pgrep -x pcscd.bin > /dev/null 2>&1; then
|
[ -x "$PYTHON_BIN" ] || PYTHON_BIN="$(command -v python3 || true)"
|
||||||
sleep 1
|
if [ -n "$PYTHON_BIN" ]; then
|
||||||
fi
|
PCSC_ERR="$("$PYTHON_BIN" - <<'PY' 2>&1
|
||||||
if pgrep -x pcscd > /dev/null 2>&1 || pgrep -x pcscd.bin > /dev/null 2>&1; then
|
import time
|
||||||
|
from smartcard.System import readers
|
||||||
|
last = None
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
|
readers()
|
||||||
|
raise SystemExit(0)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last = e
|
||||||
|
hr = getattr(e, 'hresult', None)
|
||||||
|
if hr == 0x80100027 or 'access denied' in str(e).lower():
|
||||||
|
break # permissions do not fix themselves
|
||||||
|
time.sleep(1) # pcscd may still be starting (socket activation)
|
||||||
|
hr = getattr(last, 'hresult', -1)
|
||||||
|
msg = str(last) or 'PC/SC error'
|
||||||
|
print(msg + ((' (hresult=0x%08X)' % hr) if hr not in (None, -1) else ''))
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
)" || {
|
||||||
|
echo "Error: PC/SC is not accessible: ${PCSC_ERR:-unknown error}"
|
||||||
|
echo " The server will not fall back to a serial reader silently."
|
||||||
|
echo " - pcscd uses PolicyKit (the default allows only active local sessions,"
|
||||||
|
echo " so SSH/headless is denied even with a 0666 socket). Allow the user in"
|
||||||
|
echo " /etc/polkit-1/rules.d/99-pcscd-allow.rules: return polkit.Result.YES for"
|
||||||
|
echo " org.debian.pcsc-lite.access_pcsc and access_card, subject.user == \"$USER\";"
|
||||||
|
echo " then 'sudo systemctl restart polkit' (or log out/in);"
|
||||||
|
echo " - 'sudo systemctl start pcscd' if the service is not running;"
|
||||||
|
echo " - 'pcsc_scan' shows the same 'Access denied' error until the rule is active."
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
READER_ARGS="-p 0"
|
READER_ARGS="-p 0"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
+21
-5
@@ -23,13 +23,29 @@ class FakeFile:
|
|||||||
self.fid = fid
|
self.fid = fid
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves READ RECORD for the selected fake ARR file."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
"""One selected ARR file: read_record serves its records."""
|
"""One selected ARR file: READ RECORD serves its records."""
|
||||||
|
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> [record hex]
|
self.files = files # path -> [record hex]
|
||||||
self._cur = None
|
self._cur = None
|
||||||
self.selected_file = None
|
self.selected_file = None
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
def selected_file_num_of_rec(self):
|
def selected_file_num_of_rec(self):
|
||||||
return len(self.files.get(self._cur) or [])
|
return len(self.files.get(self._cur) or [])
|
||||||
@@ -96,13 +112,13 @@ class ReadArrTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_a_failing_read_skips_the_file(self):
|
def test_a_failing_read_skips_the_file(self):
|
||||||
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
|
app, lchan = _app({'MF/2F06': ['9000'], 'ADF.USIM/6F06': ['9000']})
|
||||||
orig = lchan.read_record
|
orig = lchan.scc.send_apdu_checksw
|
||||||
|
|
||||||
def flaky(n):
|
def flaky(pdu):
|
||||||
if lchan._cur == 'MF/2F06':
|
if lchan._cur == 'MF/2F06':
|
||||||
raise RuntimeError('6A82')
|
raise RuntimeError('6A82')
|
||||||
return orig(n)
|
return orig(pdu)
|
||||||
lchan.read_record = flaky
|
lchan.scc.send_apdu_checksw = flaky
|
||||||
arrs = _read(app)
|
arrs = _read(app)
|
||||||
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
|
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ class FakeFile:
|
|||||||
self.applications = {}
|
self.applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
if self.lchan.error:
|
||||||
|
raise self.lchan.error
|
||||||
|
return self.lchan.data, '9000'
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
def __init__(self, mf, data=None, error=None):
|
def __init__(self, mf, data=None, error=None):
|
||||||
self.selected_file = mf
|
self.selected_file = mf
|
||||||
@@ -39,6 +54,10 @@ class FakeLchan:
|
|||||||
self.error = error
|
self.error = error
|
||||||
self.selects = []
|
self.selects = []
|
||||||
self.probes = []
|
self.probes = []
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
|
def selected_file_size(self):
|
||||||
|
return len(self.data or '') // 2
|
||||||
|
|
||||||
def select_file(self, f, app=None):
|
def select_file(self, f, app=None):
|
||||||
self.selected_file = f
|
self.selected_file = f
|
||||||
@@ -94,6 +113,8 @@ class ReadIccidTest(unittest.TestCase):
|
|||||||
self.assertIs(lchan.selected_file, iccid)
|
self.assertIs(lchan.selected_file, iccid)
|
||||||
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
|
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
|
||||||
self.assertEqual(lchan.probes, [])
|
self.assertEqual(lchan.probes, [])
|
||||||
|
# one READ BINARY, no second SELECT (v3.38.1)
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b000000a'])
|
||||||
|
|
||||||
def test_missing_model_entry_is_probed_and_detached(self):
|
def test_missing_model_entry_is_probed_and_detached(self):
|
||||||
app, lchan, mf, _ = make_app(with_iccid=False)
|
app, lchan, mf, _ = make_app(with_iccid=False)
|
||||||
|
|||||||
@@ -13,6 +13,28 @@ from types import SimpleNamespace
|
|||||||
from pysim_simple_server import server
|
from pysim_simple_server import server
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves the selected fake file for direct READ BINARY/RECORD APDUs."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
kind, val = self.lchan.files[self.lchan.selected]
|
||||||
|
if pdu[2:4] == 'b0':
|
||||||
|
off = int(pdu[4:8], 16)
|
||||||
|
ln = int(pdu[8:10], 16)
|
||||||
|
return val[off * 2:(off + ln) * 2], '9000'
|
||||||
|
if pdu[2:4] == 'b2':
|
||||||
|
return val[int(pdu[4:6], 16) - 1], '9000'
|
||||||
|
raise AssertionError('unexpected APDU %s' % pdu)
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> ('transparent', hex) | ('record', [hex])
|
self.files = files # path -> ('transparent', hex) | ('record', [hex])
|
||||||
@@ -23,6 +45,9 @@ class FakeLchan:
|
|||||||
self.selected_file_type = lambda: 'ef'
|
self.selected_file_type = lambda: 'ef'
|
||||||
self.selected_file_structure = lambda: self.files[self.selected][0]
|
self.selected_file_structure = lambda: self.files[self.selected][0]
|
||||||
self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1])
|
self.selected_file_num_of_rec = lambda: len(self.files[self.selected][1])
|
||||||
|
self.selected_file_record_len = lambda: 2
|
||||||
|
self.selected_file_size = lambda: len(self.files[self.selected][1]) // 2
|
||||||
|
self.scc = FakeScc(self)
|
||||||
self.read_binary = lambda: (self.files[self.selected][1], '9000')
|
self.read_binary = lambda: (self.files[self.selected][1], '9000')
|
||||||
self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
|
self.read_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
|
||||||
|
|
||||||
@@ -68,6 +93,8 @@ class NetStateServerTests(unittest.TestCase):
|
|||||||
('select', 'DF.GSM/6F07'),
|
('select', 'DF.GSM/6F07'),
|
||||||
('select', 'ADF.USIM/6FCD'),
|
('select', 'ADF.USIM/6FCD'),
|
||||||
('select', 'ADF.USIM/6FE4')])
|
('select', 'ADF.USIM/6FE4')])
|
||||||
|
# v3.38.1: the reads use the selected file directly - no second SELECT
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b0000003', '00b2010402', '00b2020402'])
|
||||||
|
|
||||||
def test_install_builds_the_state_and_network(self):
|
def test_install_builds_the_state_and_network(self):
|
||||||
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
|
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
|
||||||
|
|||||||
+21
-5
@@ -34,13 +34,29 @@ class FakeFile:
|
|||||||
self.fid = fid
|
self.fid = fid
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
"""Serves READ RECORD for the selected fake PBR file."""
|
||||||
|
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, lchan):
|
||||||
|
self.lchan = lchan
|
||||||
|
self.sent = []
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return (self.lchan.files[self.lchan._cur][int(pdu[4:6], 16) - 1], '9000')
|
||||||
|
|
||||||
|
|
||||||
class FakeLchan:
|
class FakeLchan:
|
||||||
"""One selected PBR file: read_record serves its records."""
|
"""One selected PBR file: READ RECORD serves its records."""
|
||||||
|
|
||||||
def __init__(self, files):
|
def __init__(self, files):
|
||||||
self.files = files # path -> [record hex]
|
self.files = files # path -> [record hex]
|
||||||
self._cur = None
|
self._cur = None
|
||||||
self.selected_file = None
|
self.selected_file = None
|
||||||
|
self.scc = FakeScc(self)
|
||||||
|
|
||||||
def selected_file_num_of_rec(self):
|
def selected_file_num_of_rec(self):
|
||||||
return len(self.files.get(self._cur) or [])
|
return len(self.files.get(self._cur) or [])
|
||||||
@@ -101,13 +117,13 @@ class ReadPbrTests(unittest.TestCase):
|
|||||||
def test_a_failing_read_skips_the_file(self):
|
def test_a_failing_read_skips_the_file(self):
|
||||||
app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'],
|
app, lchan = _app({'MF/7F10/5F3A/4F30': ['A80AC0034F3A02'],
|
||||||
'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']})
|
'ADF.USIM/5F3A/4F30': ['A80AC0034F3A02']})
|
||||||
orig = lchan.read_record
|
orig = lchan.scc.send_apdu_checksw
|
||||||
|
|
||||||
def flaky(n):
|
def flaky(pdu):
|
||||||
if lchan._cur == 'MF/7F10/5F3A/4F30':
|
if lchan._cur == 'MF/7F10/5F3A/4F30':
|
||||||
raise RuntimeError('6A82')
|
raise RuntimeError('6A82')
|
||||||
return orig(n)
|
return orig(pdu)
|
||||||
lchan.read_record = flaky
|
lchan.scc.send_apdu_checksw = flaky
|
||||||
pbrs = _read(app)
|
pbrs = _read(app)
|
||||||
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
|
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""PC/SC access-denied handling at startup (v3.38.1).
|
||||||
|
|
||||||
|
A user without permission to pcscd used to get a silent serial fallback that
|
||||||
|
failed on /dev/ttyUSB0 while the server still printed "listening". The
|
||||||
|
startup now classifies the pyscard failure, reports the cause with guidance
|
||||||
|
and exits; ``start.sh`` runs the same access pre-flight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import __main__ as m
|
||||||
|
|
||||||
|
|
||||||
|
class FakeExc(Exception):
|
||||||
|
def __init__(self, hresult=None, message=''):
|
||||||
|
super().__init__(message)
|
||||||
|
self.hresult = hresult
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorKindTests(unittest.TestCase):
|
||||||
|
def test_access_denied_by_hresult(self):
|
||||||
|
# pyscard raises CardConnectionException(hresult=...) with an empty
|
||||||
|
# message on the SCardEstablishContext failure
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100027)), 'access')
|
||||||
|
|
||||||
|
def test_access_denied_by_message(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(None, 'Access denied.')),
|
||||||
|
'access')
|
||||||
|
|
||||||
|
def test_service_errors(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001D)), 'service')
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010001E)), 'service')
|
||||||
|
|
||||||
|
def test_reader_errors(self):
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x8010002E)), 'reader')
|
||||||
|
self.assertEqual(m._pcsc_error_kind(FakeExc(0x80100009)), 'reader')
|
||||||
|
|
||||||
|
def test_other_failures_are_not_classified(self):
|
||||||
|
self.assertIsNone(m._pcsc_error_kind(FakeExc(0x8010000B))) # sharing
|
||||||
|
self.assertIsNone(m._pcsc_error_kind(ValueError('boom')))
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeTests(unittest.TestCase):
|
||||||
|
def test_the_first_reader_is_returned(self):
|
||||||
|
self.assertEqual(
|
||||||
|
m._probe_pcsc_reader(readers_fn=lambda: ['Reader X']), 'Reader X')
|
||||||
|
|
||||||
|
def test_access_denied_is_raised_immediately(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def denied():
|
||||||
|
calls.append(1)
|
||||||
|
raise FakeExc(0x80100027)
|
||||||
|
|
||||||
|
with self.assertRaises(m.PcscUnavailable) as cm:
|
||||||
|
m._probe_pcsc_reader(retries=3, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=denied)
|
||||||
|
self.assertEqual(cm.exception.kind, 'access')
|
||||||
|
self.assertEqual(len(calls), 1) # permissions do not fix themselves
|
||||||
|
|
||||||
|
def test_an_empty_list_is_retried_then_reported(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def empty():
|
||||||
|
calls.append(1)
|
||||||
|
return []
|
||||||
|
|
||||||
|
with self.assertRaises(m.PcscUnavailable) as cm:
|
||||||
|
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=empty)
|
||||||
|
self.assertEqual(cm.exception.kind, 'reader')
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
def test_a_transient_failure_is_retried(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def flaky():
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise FakeExc(0x8010000B) # sharing violation
|
||||||
|
return ['Reader Y']
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
m._probe_pcsc_reader(retries=2, sleep_fn=lambda s: None,
|
||||||
|
readers_fn=flaky), 'Reader Y')
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTests(unittest.TestCase):
|
||||||
|
def _report(self, kind, exc=None):
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch('sys.stderr', err):
|
||||||
|
m._report_pcsc_unavailable(kind, exc)
|
||||||
|
return err.getvalue()
|
||||||
|
|
||||||
|
def test_access_guidance(self):
|
||||||
|
text = self._report('access', FakeExc(0x80100027, 'Access denied.'))
|
||||||
|
low = text.lower()
|
||||||
|
self.assertIn('access denied', low)
|
||||||
|
self.assertIn('pcscd', low)
|
||||||
|
# polkit, not the socket group, is the gate (the socket can be 0666)
|
||||||
|
self.assertIn('polkit', low)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_pcsc', low)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_card', low)
|
||||||
|
self.assertIn('99-pcscd-allow.rules', low)
|
||||||
|
self.assertIn('0666', low)
|
||||||
|
self.assertIn('pcsc_scan', low)
|
||||||
|
self.assertIn('0x80100027', text)
|
||||||
|
|
||||||
|
def test_service_guidance(self):
|
||||||
|
text = self._report('service')
|
||||||
|
self.assertIn('pcscd is not running', text)
|
||||||
|
self.assertIn('systemctl start pcscd', text)
|
||||||
|
|
||||||
|
def test_reader_guidance(self):
|
||||||
|
text = self._report('reader')
|
||||||
|
self.assertIn('no PC/SC reader', text)
|
||||||
|
self.assertIn('pcsc_scan', text)
|
||||||
|
|
||||||
|
|
||||||
|
class SerialArgTests(unittest.TestCase):
|
||||||
|
def test_explicit_serial_flags(self):
|
||||||
|
for argv in (['-d', '/dev/ttyS0'], ['--device', 'x'],
|
||||||
|
['--device=x'], ['-d/dev/ttyS0']):
|
||||||
|
self.assertTrue(m._serial_arg_given(argv), argv)
|
||||||
|
|
||||||
|
def test_the_default_is_not_an_explicit_choice(self):
|
||||||
|
for argv in ([], ['--http-port', '8080'], ['-p', '0']):
|
||||||
|
self.assertFalse(m._serial_arg_given(argv), argv)
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePins(unittest.TestCase):
|
||||||
|
def test_the_module_propagates_the_exit_code(self):
|
||||||
|
src = Path(m.__file__).read_text()
|
||||||
|
self.assertIn('sys.exit(main())', src)
|
||||||
|
|
||||||
|
def test_the_start_script_probes_access_and_guides(self):
|
||||||
|
src = (Path(m.__file__).parent.parent / 'start.sh').read_text()
|
||||||
|
self.assertIn('from smartcard.System import readers', src)
|
||||||
|
self.assertIn('polkit', src)
|
||||||
|
self.assertIn('org.debian.pcsc-lite.access_pcsc', src)
|
||||||
|
self.assertIn('99-pcscd-allow.rules', src)
|
||||||
|
self.assertIn('pcsc_scan', src)
|
||||||
|
# the group route is not the gate (the socket may be 0666)
|
||||||
|
self.assertNotIn('usermod -aG pcscd', src)
|
||||||
|
# the old "is pcscd running" heuristic is gone
|
||||||
|
self.assertNotIn('pgrep -x pcscd', src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""The direct read of an already-selected EF (v3.38.1).
|
||||||
|
|
||||||
|
pySim's ``scc.read_binary``/``scc.read_record`` select the file first; the
|
||||||
|
equip/init readers have already made a parent-scoped selection and hold its
|
||||||
|
FCP, so that second SELECT is pure overhead (one per file, one per record).
|
||||||
|
These tests pin the direct READ BINARY/READ RECORD form, the chunking and the
|
||||||
|
fallback when the FCP lacks the size/record length.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pysim_simple_server import server as S
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScc:
|
||||||
|
cla_byte = '00'
|
||||||
|
max_cmd_len = 255
|
||||||
|
|
||||||
|
def __init__(self, data='AABB'):
|
||||||
|
self.sent = []
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def send_apdu_checksw(self, pdu):
|
||||||
|
self.sent.append(pdu)
|
||||||
|
return self.data, '9000'
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLchan:
|
||||||
|
def __init__(self, size=2, rec_len=2, data='AABB'):
|
||||||
|
self.scc = FakeScc(data)
|
||||||
|
self.size = size
|
||||||
|
self.rec_len = rec_len
|
||||||
|
|
||||||
|
def selected_file_size(self):
|
||||||
|
return self.size
|
||||||
|
|
||||||
|
def selected_file_record_len(self):
|
||||||
|
return self.rec_len
|
||||||
|
|
||||||
|
def read_binary(self, *args):
|
||||||
|
raise AssertionError('pySim read_binary (re-select) used')
|
||||||
|
|
||||||
|
def read_record(self, *args):
|
||||||
|
raise AssertionError('pySim read_record (re-select) used')
|
||||||
|
|
||||||
|
|
||||||
|
class ReadSelectedTests(unittest.TestCase):
|
||||||
|
def test_a_transparent_read_is_direct(self):
|
||||||
|
lchan = FakeLchan(size=2)
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('AABB', '9000'))
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b0000002'])
|
||||||
|
|
||||||
|
def test_a_transparent_file_is_chunked_at_max_cmd_len(self):
|
||||||
|
lchan = FakeLchan(size=300)
|
||||||
|
S._read_selected_ef(lchan, 'transparent')
|
||||||
|
# 255 bytes at offset 0, then 45 bytes at offset 0x00ff
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b00000ff', '00b000ff2d'])
|
||||||
|
|
||||||
|
def test_an_empty_file_reads_nothing(self):
|
||||||
|
lchan = FakeLchan(size=0)
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('', '9000'))
|
||||||
|
self.assertEqual(lchan.scc.sent, [])
|
||||||
|
|
||||||
|
def test_a_record_read_is_direct(self):
|
||||||
|
lchan = FakeLchan(rec_len=6)
|
||||||
|
S._read_selected_ef(lchan, 'record', 3)
|
||||||
|
self.assertEqual(lchan.scc.sent, ['00b2030406'])
|
||||||
|
|
||||||
|
def test_a_missing_fcp_falls_back_to_the_pysim_helpers(self):
|
||||||
|
lchan = FakeLchan(size=None, rec_len=None)
|
||||||
|
lchan.read_binary = lambda *a: ('CC', '9000')
|
||||||
|
lchan.read_record = lambda n: ('DD', '9000')
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'transparent'),
|
||||||
|
('CC', '9000'))
|
||||||
|
self.assertEqual(S._read_selected_ef(lchan, 'record', 1),
|
||||||
|
('DD', '9000'))
|
||||||
|
|
||||||
|
|
||||||
|
class StartupDrainPin(unittest.TestCase):
|
||||||
|
"""The startup drain must not poll STATUS again after the chain already
|
||||||
|
returned an idle SW (v3.38.1)."""
|
||||||
|
|
||||||
|
def test_the_drain_uses_the_chain_result(self):
|
||||||
|
src = (pathlib.Path(S.__file__).parent / '__main__.py').read_text()
|
||||||
|
self.assertIn('chain_sw = _handle_proactive_chain(scc, st_sw)', src)
|
||||||
|
self.assertIn("if chain_sw and not chain_sw.startswith('91'):", src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""The setup scripts recover from an incomplete .venv (v3.38.1).
|
||||||
|
|
||||||
|
A failed venv creation (the Debian/Ubuntu python3-venv package missing) used to
|
||||||
|
leave the .venv directory behind; the next run saw it, skipped creation and
|
||||||
|
failed to activate a venv without activate/pip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class SetupShTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.src = (ROOT / 'setup.sh').read_text()
|
||||||
|
|
||||||
|
def test_an_incomplete_venv_is_removed_before_creation(self):
|
||||||
|
self.assertIn('Removing an incomplete virtual environment', self.src)
|
||||||
|
self.assertIn('rm -rf "$VENV_DIR"', self.src)
|
||||||
|
self.assertIn('bin/activate', self.src)
|
||||||
|
self.assertIn('bin/python', self.src)
|
||||||
|
self.assertIn('bin/pip', self.src)
|
||||||
|
|
||||||
|
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
|
||||||
|
self.assertIn('if ! python3 -m venv "$VENV_DIR"; then', self.src)
|
||||||
|
self.assertIn('python3-venv', self.src)
|
||||||
|
fails = self.src[self.src.index('if ! python3 -m venv'):]
|
||||||
|
self.assertLess(fails.index('rm -rf "$VENV_DIR"'),
|
||||||
|
fails.index('could not create the virtual environment'))
|
||||||
|
|
||||||
|
def test_the_script_is_valid_bash(self):
|
||||||
|
bash = shutil.which('bash')
|
||||||
|
if not bash:
|
||||||
|
self.skipTest('bash not available')
|
||||||
|
res = subprocess.run([bash, '-n', str(ROOT / 'setup.sh')],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(res.returncode, 0, res.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class SetupBatTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.src = (ROOT / 'setup.bat').read_text()
|
||||||
|
|
||||||
|
def test_an_incomplete_venv_is_removed_before_creation(self):
|
||||||
|
self.assertIn('Removing an incomplete virtual environment', self.src)
|
||||||
|
self.assertIn('rmdir /s /q "%VENV_DIR%"', self.src)
|
||||||
|
for marker in ('Scripts\\activate.bat', 'Scripts\\python.exe',
|
||||||
|
'Scripts\\pip.exe'):
|
||||||
|
self.assertIn(marker, self.src)
|
||||||
|
|
||||||
|
def test_a_failed_creation_removes_the_partial_dir_and_guides(self):
|
||||||
|
self.assertIn('if !errorlevel! neq 0', self.src)
|
||||||
|
self.assertIn('python -m venv "%VENV_DIR%"', self.src)
|
||||||
|
fails = self.src[self.src.index('if !errorlevel! neq 0'):]
|
||||||
|
self.assertLess(fails.index('rmdir /s /q "%VENV_DIR%"'),
|
||||||
|
fails.index('could not create the virtual environment'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user