5 Commits
Author SHA1 Message Date
catarrh 393b1098ce release: date the 3.38.1 entry
release / release (push) Has been cancelled
2026-10-25 00:52:48 +03:00
catarrh 295f5553fc pcsc: guide by PolicyKit actions, not a pcscd group
A headless/SSH box can show a world-writable /run/pcscd/pcscd.comm
(srw-rw-rw- root root) and still be denied: pcscd enforces its PolicyKit
policy, whose defaults allow only active local sessions.  The startup
report, the start.sh pre-flight and the README troubleshooting now name
the exact rule (99-pcscd-allow.rules, org.debian.pcsc-lite.access_pcsc
and access_card) and explain why the socket mode does not matter.  The
pcscd-group instruction is gone: there may be no such group, and it is
not the gate.  The group route stays only as a note for a pcscd built
without PolicyKit.

Tests: tests/test_pcsc_startup.py asserts the polkit action IDs, the
rule filename and the 0666-socket explanation.
1088 Python + 1075 frontend.
2026-10-25 00:50:24 +03:00
catarrh 2ecf7bb45c setup: recreate an incomplete .venv instead of activating it
A failed venv creation (e.g. the Debian/Ubuntu python3-venv package
missing) left the .venv directory behind; the next run saw it, skipped
creation and failed to source/activate a venv without activate/pip.
setup.sh now validates bin/activate, bin/python and bin/pip, removes an
incomplete directory and creates it again; a creation failure removes the
partial directory and names the venv package.  setup.bat does the same
with Scripts\activate.bat/python.exe/pip.exe and !errorlevel!.

READMEs (prerequisites + scripts table), CHANGELOG (folded into the
unreleased 3.38.1) and AGENTS updated.

Tests: tests/test_setup_scripts.py (5: the shell/bat source pins and
bash -n).  1088 Python + 1075 frontend.
2026-10-25 00:42:36 +03:00
catarrh a269f8bd2e startup: report PC/SC access denial 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 auto-detect swallowed the error and pySim
fell back to the Serial transport, failing on /dev/ttyUSB0 while the
server still printed "listening".  The startup now probes PC/SC,
classifies the failure by hresult (access/service/reader), reports the
cause with the pcscd group/polkit and pcsc_scan guidance and exits 2; the
python -m guard uses sys.exit(main()).  An explicit -d/--device keeps the
serial path; a reader without a card stays a normal cardless start; a
readerless start exits with its own message.

start.sh runs the same access pre-flight before launching (readers() with
a 3x retry for socket activation; -d is detected and skips it).  READMEs,
CHANGELOG (folded into the unreleased 3.38.1) and AGENTS updated.

Tests: tests/test_pcsc_startup.py (16: the error classifier, the probe,
the report guidance, the serial-arg detection and the source pins).
1083 Python + 1075 frontend.
2026-10-25 00:29:26 +03:00
catarrh 0231df905c equip/init: read the selected EF directly, bound the startup drain
pySim's scc.read_binary/read_record call select_path(ef) first, so the
ICCID, network-state, EF.ARR and EF.PBR readers - which had already made a
parent-scoped selection and hold its FCP - sent a second SELECT per file
and per record.  _read_selected_ef() issues READ BINARY (chunked at
max_cmd_len from the FCP file size) / READ RECORD (b2<rec>04<len> at the
FCP record length) directly; the pySim helpers stay as the fallback when
the FCP lacks the value.

The startup TERMINAL PROFILE drain now breaks on the chain's idle SW
instead of polling STATUS once more.

Measured on the last startup: ~3.4 s traced init (netstate/ARR/PBR 271
APDUs / 1.26 s incl. 169 SELECTs; the card's own TP processing 1.58 s);
the change removes ~0.7-0.8 s of the read phase without touching any data
or the TP.

v3.38.1; tests: tests/test_read_selected.py (direct reads, chunking, the
fallback and the drain source pin) and the ARR/PBR/ICCID/netstate fakes.
1067 Python + 1075 frontend.  sw cache simple-v482.
2026-10-24 18:54:31 +03:00
18 changed files with 747 additions and 56 deletions

No files matched your search

+38
View File
@@ -9,6 +9,44 @@ every change carries a descriptive commit message.
## [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
### Added
+18 -4
View File
@@ -748,6 +748,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki)
### 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 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).
- **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 |
|--------|-------------|
| `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`). |
| `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)
@@ -799,8 +800,21 @@ its MSVC build chain) is not pulled in.
### 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.
- **"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):
```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.
- **"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
View File
@@ -732,6 +732,7 @@ PWA проверяет версию сервера при подключении
### Требования
- **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`).
- **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`). |
| `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)
@@ -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`.
- **"device file /dev/ttyUSB0 does not exist"** — нет serial-ридера; подключите USB-ридер или укажите `-d`. Сервер всё равно стартует без ридера.
- **"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` может быть любым):
```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 (сначала освобождая старый линк). Перезапуск сервера не нужен.
- **Ошибки "Card was removed" / equip не срабатывает после смены карты** — вставка карты помечает соединение PC/SC для пересоздания (поэтому **первая** попытка уже использует свежее соединение — в Windows старое после смены карты подключиться не может), авто-equip повторяется до 3 раз, и **каждая неудачная попытка пересоздаёт транспорт** перед следующей. Карта, снятая во время инициализации при старте сервера, оставляет его без карты с одной строкой (без трассировки), а следующая вставка инициализируется обычным образом. Проглоченная ошибка инициализации (с трассировкой) теперь печатается как `AUTO-EQUIP: init output:`, а «карта есть, но карта не создана» — это ошибка, а не тихое «карта пропала». Сторож повторяет с удвоением задержки (5 с … 60 с), пока карта в ридере; снятие карты полностью очищает состояние, поэтому обработчики отвечают «нет карты», а не шлют APDU мёртвой карте.
+1 -1
View File
@@ -1959,7 +1959,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// 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;
// ===== view history (the browser Back/Forward buttons) =====
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v481';
const CACHE = 'simple-v482';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.38.0"
version = "3.38.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+136 -19
View File
@@ -7,7 +7,7 @@ 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.exceptions import NoCardError, ReaderError
from pySim.log import PySimLogger
from pySim.cards import UiccCardBase
@@ -23,6 +23,115 @@ from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION,
_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):
elapsed = time.time() - _server_start
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
@@ -164,22 +273,18 @@ def main():
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:
# Auto-detect the PC/SC reader unless one was named explicitly or the
# operator chose the serial transport (-d). A PC/SC access denial or a
# 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:
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
reader = _probe_pcsc_reader()
except PcscUnavailable as e:
_report_pcsc_unavailable(e.kind, e.cause)
return 2
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % reader)
opts.pcsc_dev = 0
try:
kwargs = {}
@@ -223,7 +328,15 @@ def main():
# 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:
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)
traceback.print_exc()
ch = CardHandler(sl) if sl else None
@@ -293,7 +406,11 @@ def main():
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
if not st_sw.startswith('91'):
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))
except Exception as e:
if _is_pcsc_error(e):
@@ -434,4 +551,4 @@ def main():
if __name__ == '__main__':
main()
sys.exit(main())
+45 -6
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
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
@@ -483,6 +483,45 @@ def _decode_iccid(data_hex):
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):
"""Best-effort EF.ICCID (MF/2FE2) read: the E.118 digit string or None.
@@ -495,7 +534,7 @@ def _read_iccid(app):
cleanup = None
try:
_, cleanup = _select_path(lchan, 'MF/2FE2', app)
data, _sw = lchan.read_binary()
data, _sw = _read_selected_ef(lchan, 'transparent')
return _decode_iccid(data)
except Exception:
return None
@@ -536,7 +575,7 @@ def _read_arr(app):
n = min(lchan.selected_file_num_of_rec() or 1, ARR_RECORD_LIMIT)
records = []
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
records.append((data or '').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)
records = []
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
records.append((data or '').upper())
out.append({'fid': path.rsplit('/', 1)[-1].upper(),
@@ -886,14 +925,14 @@ def _netstate_read(app, keys=None):
records = []
n = lchan.selected_file_num_of_rec() or 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
records.append({'num': i,
'data': (data or '').upper()})
entry.update({'present': True, 'kind': 'record',
'records': records})
else:
rv = lchan.read_binary()
rv = _read_selected_ef(lchan, 'transparent')
data = rv[0] if isinstance(rv, tuple) else rv
entry.update({'present': True, 'kind': 'transparent',
'data': (data or '').upper()})
+26 -1
View File
@@ -34,10 +34,35 @@ echo Found Git
git --version
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%" (
echo Creating virtual environment...
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"
+16 -2
View File
@@ -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}")')
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
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
source "$VENV_DIR/bin/activate"
PIP="$VENV_DIR/bin/pip"
+46 -7
View File
@@ -16,14 +16,53 @@ LOG_FILE="$SCRIPT_DIR/simple_lastrun.log"
exec > >(tee "$LOG_FILE") 2>&1
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=""
if command -v pcscd > /dev/null 2>&1; then
# Give pcscd a moment if it's not running yet (USB enumeration delay)
if ! pgrep -x pcscd > /dev/null 2>&1 && ! pgrep -x pcscd.bin > /dev/null 2>&1; then
sleep 1
fi
if pgrep -x pcscd > /dev/null 2>&1 || pgrep -x pcscd.bin > /dev/null 2>&1; then
if [ "$SERIAL" -eq 0 ]; then
PYTHON_BIN="$VENV_DIR/bin/python"
[ -x "$PYTHON_BIN" ] || PYTHON_BIN="$(command -v python3 || true)"
if [ -n "$PYTHON_BIN" ]; then
PCSC_ERR="$("$PYTHON_BIN" - <<'PY' 2>&1
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"
fi
fi
+21 -5
View File
@@ -23,13 +23,29 @@ class FakeFile:
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:
"""One selected ARR file: read_record serves its records."""
"""One selected ARR file: READ RECORD serves its records."""
def __init__(self, files):
self.files = files # path -> [record hex]
self._cur = None
self.selected_file = None
self.scc = FakeScc(self)
def selected_file_num_of_rec(self):
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):
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':
raise RuntimeError('6A82')
return orig(n)
lchan.read_record = flaky
return orig(pdu)
lchan.scc.send_apdu_checksw = flaky
arrs = _read(app)
self.assertEqual([a['fid'] for a in arrs], ['6F06'])
+21
View File
@@ -32,6 +32,21 @@ class FakeFile:
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:
def __init__(self, mf, data=None, error=None):
self.selected_file = mf
@@ -39,6 +54,10 @@ class FakeLchan:
self.error = error
self.selects = []
self.probes = []
self.scc = FakeScc(self)
def selected_file_size(self):
return len(self.data or '') // 2
def select_file(self, f, app=None):
self.selected_file = f
@@ -94,6 +113,8 @@ class ReadIccidTest(unittest.TestCase):
self.assertIs(lchan.selected_file, iccid)
self.assertEqual([f.name for f in lchan.selects], ['MF', 'EF.ICCID'])
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):
app, lchan, mf, _ = make_app(with_iccid=False)
+27
View File
@@ -13,6 +13,28 @@ from types import SimpleNamespace
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:
def __init__(self, files):
self.files = files # path -> ('transparent', hex) | ('record', [hex])
@@ -23,6 +45,9 @@ class FakeLchan:
self.selected_file_type = lambda: 'ef'
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_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_record = lambda i: (self.files[self.selected][1][i - 1], '9000')
@@ -68,6 +93,8 @@ class NetStateServerTests(unittest.TestCase):
('select', 'DF.GSM/6F07'),
('select', 'ADF.USIM/6FCD'),
('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):
files = {'imsi': {'name': 'EF.IMSI', 'fid': '6F07', 'present': True,
+21 -5
View File
@@ -34,13 +34,29 @@ class FakeFile:
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:
"""One selected PBR file: read_record serves its records."""
"""One selected PBR file: READ RECORD serves its records."""
def __init__(self, files):
self.files = files # path -> [record hex]
self._cur = None
self.selected_file = None
self.scc = FakeScc(self)
def selected_file_num_of_rec(self):
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):
app, lchan = _app({'MF/7F10/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':
raise RuntimeError('6A82')
return orig(n)
lchan.read_record = flaky
return orig(pdu)
lchan.scc.send_apdu_checksw = flaky
pbrs = _read(app)
self.assertEqual([p['path'] for p in pbrs], ['ADF.USIM/5F3A/4F30'])
+155
View File
@@ -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()
+93
View File
@@ -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()
+63
View File
@@ -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()