295f5553fc
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.
156 lines
5.6 KiB
Python
156 lines
5.6 KiB
Python
"""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()
|