v3.30.3: the Windows console encoding guard
Under start.bat the server's stdout/stderr are pipes into PowerShell's Tee-Object, so Python encodes with the ANSI code page instead of the wide console API. cp1251 (Russian Windows) cannot encode the banner's box-drawing rule (U+2500), and that print killed the server with UnicodeEncodeError before it could listen. - _guard_console_streams() (the first statement of main()) sets errors="replace" on both streams: an unencodable character prints as '?' and can never raise. - _console_rule() picks the widest banner rule its stream can encode - U+2500 where the code page has it (utf-8, cp437), ASCII dashes otherwise (cp1251, ascii). - The seven text-layer trace strings are ASCII now: the APDU arrows (U+2192) and the INIT/CAP-COMPAT em dashes (U+2014 - cp437 cannot encode them either), so the traces stay readable on any code page. The raw os.write paths (_log_stdout, the APDU tracer) bypass the text layer, cannot crash, and were left as-is. Tests: tests/test_console_encoding.py - the strict-cp1251 control, the rule's fallback/pretty cases, the guarded banner+traces, a stream without reconfigure, the main() source pin, and an AST pin that no print/stdout/stderr literal in the package is non-ASCII. tests/test_startup_cardless.py's CARDLESS_LINE follows the new ASCII line. Verified with PYTHONUNBUFFERED=1 PYTHONIOENCODING=cp1251: the pre-fix code died at the banner, the fixed code reaches the listening banner and serves a command (CMD: -> OK, no encode errors). 930 frontend + 952 Python; sw cache simple-v453.
This commit is contained in:
+1
-1
@@ -1932,7 +1932,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.30.2';
|
const SIMPLE_VERSION = '3.30.3';
|
||||||
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-v452';
|
const CACHE = 'simple-v453';
|
||||||
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.30.2"
|
version = "3.30.3"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ def _log_stdout(msg):
|
|||||||
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_console_streams():
|
||||||
|
"""Never let a non-encodable character kill the server.
|
||||||
|
|
||||||
|
On Windows the launcher pipes stdout/stderr into PowerShell (see start.bat),
|
||||||
|
so Python encodes with the ANSI code page instead of the wide console API -
|
||||||
|
cp1251 cannot encode the banner's box-drawing rule (U+2500) or the APDU
|
||||||
|
trace arrows (U+2192), and one such character raised UnicodeEncodeError and
|
||||||
|
killed the process. errors="replace" keeps every message, whatever the code
|
||||||
|
page (a character the code page lacks prints as '?')."""
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
stream.reconfigure(errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _console_rule(n=70):
|
||||||
|
"""The banner rule in the widest character the output stream can encode.
|
||||||
|
|
||||||
|
Under start.bat stdout is a pipe (not a console), so Python encodes with the
|
||||||
|
ANSI code page - cp1251 (Russian Windows) cannot encode the box-drawing
|
||||||
|
U+2500 and falls back to a plain ASCII rule (cp437 has the box-drawing
|
||||||
|
characters and keeps the pretty one)."""
|
||||||
|
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||||
|
try:
|
||||||
|
"\u2500".encode(enc)
|
||||||
|
return "\u2500" * n
|
||||||
|
except (UnicodeEncodeError, LookupError):
|
||||||
|
return "-" * n
|
||||||
|
|
||||||
|
|
||||||
def _default_web_dir():
|
def _default_web_dir():
|
||||||
# <repo>/frontend, whether run from source or an editable install.
|
# <repo>/frontend, whether run from source or an editable install.
|
||||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
||||||
@@ -56,6 +87,7 @@ def _build_http_server(host, port, handler):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
global _server_start
|
global _server_start
|
||||||
|
_guard_console_streams()
|
||||||
_server_start = time.time()
|
_server_start = time.time()
|
||||||
mod = load_pysim_app()
|
mod = load_pysim_app()
|
||||||
parser = mod.option_parser
|
parser = mod.option_parser
|
||||||
@@ -167,7 +199,7 @@ def main():
|
|||||||
# Normal cardless start: there was no card in the reader (the
|
# Normal cardless start: there was no card in the reader (the
|
||||||
# 3s wait timed out). The presence monitor auto-equips once a
|
# 3s wait timed out). The presence monitor auto-equips once a
|
||||||
# card appears; nothing to recover, no traceback.
|
# card appears; nothing to recover, no traceback.
|
||||||
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||||
except Exception:
|
except Exception:
|
||||||
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
@@ -176,13 +208,13 @@ def main():
|
|||||||
except NoCardError:
|
except NoCardError:
|
||||||
# The fallback retried the cardless wait; still a normal
|
# The fallback retried the cardless wait; still a normal
|
||||||
# cardless start, not an initialization failure.
|
# cardless start, not an initialization failure.
|
||||||
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
sl.wait_for_card(3)
|
sl.wait_for_card(3)
|
||||||
rs, card = mod.init_card(sl, opts.skip_card_init)
|
rs, card = mod.init_card(sl, opts.skip_card_init)
|
||||||
except NoCardError:
|
except NoCardError:
|
||||||
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
|
||||||
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
||||||
if card is not None:
|
if card is not None:
|
||||||
# Fast init builds the card on its own SimCardCommands instance;
|
# Fast init builds the card on its own SimCardCommands instance;
|
||||||
@@ -361,11 +393,11 @@ def main():
|
|||||||
# that event triggers auto-equip instead — the desired retry.
|
# that event triggers auto-equip instead — the desired retry.
|
||||||
if sl is not None and getattr(sl, '_reader', None) is not None:
|
if sl is not None and getattr(sl, '_reader', None) is not None:
|
||||||
start_card_monitor(str(sl._reader))
|
start_card_monitor(str(sl._reader))
|
||||||
print("─" * 70)
|
print(_console_rule())
|
||||||
print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
||||||
print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)."
|
print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)."
|
||||||
% (opts.http_host, opts.http_port))
|
% (opts.http_host, opts.http_port))
|
||||||
print("─" * 70)
|
print(_console_rule())
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.30.2'
|
VERSION = '3.30.3'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -9203,7 +9203,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
elif is_equip:
|
elif is_equip:
|
||||||
output += ('EQUIP: the shell did not register its command sets - '
|
output += ('EQUIP: the shell did not register its command sets - '
|
||||||
'see the output above\n')
|
'see the output above\n')
|
||||||
sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed))
|
sys.stderr.write("CMD: %s -> %s (%dms)\n" % (cmd, status, elapsed))
|
||||||
resp = {'output': output, 'stop': bool(stop)}
|
resp = {'output': output, 'stop': bool(stop)}
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
@@ -9221,13 +9221,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
data, sw = scc.send_apdu_checksw(apdu_hex)
|
data, sw = scc.send_apdu_checksw(apdu_hex)
|
||||||
elapsed = int((time.time() - t0) * 1000)
|
elapsed = int((time.time() - t0) * 1000)
|
||||||
resp = {'response': data, 'sw': sw}
|
resp = {'response': data, 'sw': sw}
|
||||||
sys.stderr.write("APDU: %s → SW: %s (%dms)\n" % (apdu_hex, sw, elapsed))
|
sys.stderr.write("APDU: %s -> SW: %s (%dms)\n" % (apdu_hex, sw, elapsed))
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = int((time.time() - t0) * 1000)
|
elapsed = int((time.time() - t0) * 1000)
|
||||||
err = {'error': str(e)}
|
err = {'error': str(e)}
|
||||||
sys.stderr.write("APDU: %s → ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed))
|
sys.stderr.write("APDU: %s -> ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed))
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
elif self.path == '/api/verify-adm':
|
elif self.path == '/api/verify-adm':
|
||||||
@@ -9253,12 +9253,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
# the ADM is verified for the current card session
|
# the ADM is verified for the current card session
|
||||||
self.server.adm_session = getattr(self.server, 'card_session', 0)
|
self.server.adm_session = getattr(self.server, 'card_session', 0)
|
||||||
self.server.adm_failed_session = None
|
self.server.adm_failed_session = None
|
||||||
sys.stderr.write('VERIFY ADM → SW: %s\n' % resp.get('sw'))
|
sys.stderr.write('VERIFY ADM -> SW: %s\n' % resp.get('sw'))
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
resp = {'ok': False, 'error': str(e)}
|
resp = {'ok': False, 'error': str(e)}
|
||||||
sys.stderr.write('VERIFY ADM → ERROR: %s\n' % e)
|
sys.stderr.write('VERIFY ADM -> ERROR: %s\n' % e)
|
||||||
self._send_json(resp, 500)
|
self._send_json(resp, 500)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
elif self.path == '/api/esim/profile':
|
elif self.path == '/api/esim/profile':
|
||||||
@@ -10357,7 +10357,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
block_size = block_size_req or 240
|
block_size = block_size_req or 240
|
||||||
boundary, total_blocks = _cap_compat_blocks(loadfile_data, block_size)
|
boundary, total_blocks = _cap_compat_blocks(loadfile_data, block_size)
|
||||||
sys.stderr.write('CAP-COMPAT: %s — the import list ends in LOAD %d/%d '
|
sys.stderr.write('CAP-COMPAT: %s - the import list ends in LOAD %d/%d '
|
||||||
'(block size %d)\n' % (loadfile_aid, boundary,
|
'(block size %d)\n' % (loadfile_aid, boundary,
|
||||||
total_blocks, block_size))
|
total_blocks, block_size))
|
||||||
|
|
||||||
@@ -10430,7 +10430,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
resp['failed_step'] = len(steps)
|
resp['failed_step'] = len(steps)
|
||||||
failed_rec = steps[-1] if steps else {}
|
failed_rec = steps[-1] if steps else {}
|
||||||
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
|
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
|
||||||
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
|
sys.stderr.write('CAP-COMPAT: %s - imports %s (LOADS to %d/%d)\n' % (
|
||||||
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
|
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the Windows console-encoding guard (v3.30.3).
|
||||||
|
|
||||||
|
Under start.bat the server's stdout/stderr are pipes into PowerShell's
|
||||||
|
Tee-Object, so Python encodes with the ANSI code page instead of the wide
|
||||||
|
console API. The banner's box-drawing rule (U+2500) and the APDU-trace arrows
|
||||||
|
(U+2192) are not encodable in cp1251 (Russian Windows), and one such character
|
||||||
|
raised UnicodeEncodeError and killed the process before it could listen. The
|
||||||
|
guard sets errors="replace" on both streams and the banner picks the widest
|
||||||
|
rule character its stream can encode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pysim_simple_server import __main__ as srv_main
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleEncodingTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._stdout, self._stderr = sys.stdout, sys.stderr
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
sys.stdout, sys.stderr = self._stdout, self._stderr
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pipe(encoding='cp1251'):
|
||||||
|
"""A strict text stream over a pipe-like buffer - what start.bat gives
|
||||||
|
the server on Windows (a pipe, so Python uses the ANSI code page)."""
|
||||||
|
raw = io.BytesIO()
|
||||||
|
return io.TextIOWrapper(raw, encoding=encoding), raw
|
||||||
|
|
||||||
|
def test_a_strict_cp1251_pipe_dies_on_the_banner(self):
|
||||||
|
# the control: the pre-fix failure is real, so the tests below are
|
||||||
|
# meaningful (a fix that forgets the rule would be caught here)
|
||||||
|
out, _ = self._pipe()
|
||||||
|
with self.assertRaises(UnicodeEncodeError):
|
||||||
|
out.write('\u2500' * 70)
|
||||||
|
|
||||||
|
def test_the_rule_falls_back_to_ascii_on_a_cp1251_pipe(self):
|
||||||
|
out, _ = self._pipe()
|
||||||
|
sys.stdout = out
|
||||||
|
rule = srv_main._console_rule()
|
||||||
|
self.assertEqual(rule, '-' * 70)
|
||||||
|
self.assertTrue(rule.isascii())
|
||||||
|
|
||||||
|
def test_the_rule_stays_pretty_on_a_utf8_stream(self):
|
||||||
|
out, _ = self._pipe(encoding='utf-8')
|
||||||
|
sys.stdout = out
|
||||||
|
self.assertEqual(srv_main._console_rule(), '\u2500' * 70)
|
||||||
|
|
||||||
|
def test_the_guard_keeps_the_banner_and_the_traces_alive(self):
|
||||||
|
out, out_raw = self._pipe()
|
||||||
|
err, err_raw = self._pipe()
|
||||||
|
sys.stdout, sys.stderr = out, err
|
||||||
|
srv_main._guard_console_streams()
|
||||||
|
print(srv_main._console_rule()) # the listening banner
|
||||||
|
sys.stderr.write('CMD: EQUIP \u2192 ok\n') # the APDU trace
|
||||||
|
sys.stderr.write('CAP-COMPAT: x \u2014 y\n') # the CAP em dash
|
||||||
|
out.flush()
|
||||||
|
err.flush()
|
||||||
|
self.assertEqual(out_raw.getvalue().decode('cp1251').strip(), '-' * 70)
|
||||||
|
# the arrow cannot be encoded - it prints as '?', never raising; the
|
||||||
|
# em dash is in cp1251 and stays
|
||||||
|
self.assertEqual(err_raw.getvalue().decode('cp1251').splitlines(),
|
||||||
|
['CMD: EQUIP ? ok', 'CAP-COMPAT: x \u2014 y'])
|
||||||
|
|
||||||
|
def test_the_guard_tolerates_streams_without_reconfigure(self):
|
||||||
|
sys.stdout = io.StringIO()
|
||||||
|
srv_main._guard_console_streams() # must not raise
|
||||||
|
print('still works')
|
||||||
|
self.assertEqual(sys.stdout.getvalue(), 'still works\n')
|
||||||
|
|
||||||
|
def test_main_guards_the_streams_and_uses_the_rule(self):
|
||||||
|
src = inspect.getsource(srv_main.main)
|
||||||
|
self.assertIn('_guard_console_streams()', src)
|
||||||
|
self.assertEqual(src.count('print(_console_rule())'), 2)
|
||||||
|
# the raw pretty character must not come back to the banner
|
||||||
|
self.assertNotIn('\u2500', src)
|
||||||
|
|
||||||
|
def test_no_console_write_carries_a_non_ascii_literal(self):
|
||||||
|
"""Every print()/stdout/stderr write in the package stays ASCII: a
|
||||||
|
character the ANSI code page lacks would kill a piped start.bat run
|
||||||
|
(the guard degrades it to '?', but the traces should stay readable)."""
|
||||||
|
pkg = pathlib.Path(srv_main.__file__).resolve().parent
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(pkg.glob('*.py')):
|
||||||
|
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
f = node.func
|
||||||
|
is_print = isinstance(f, ast.Name) and f.id == 'print'
|
||||||
|
is_write = (isinstance(f, ast.Attribute) and f.attr == 'write'
|
||||||
|
and isinstance(f.value, ast.Attribute)
|
||||||
|
and f.value.attr in ('stdout', 'stderr'))
|
||||||
|
if not (is_print or is_write):
|
||||||
|
continue
|
||||||
|
for a in ast.walk(node):
|
||||||
|
if isinstance(a, ast.Constant) and isinstance(a.value, str):
|
||||||
|
bad = ''.join(c for c in a.value if ord(c) > 127)
|
||||||
|
if bad:
|
||||||
|
offenders.append('%s:%d: %r'
|
||||||
|
% (path.name, node.lineno, bad))
|
||||||
|
self.assertEqual(offenders, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -24,7 +24,7 @@ from pySim.exceptions import NoCardError, SwMatchError
|
|||||||
from pysim_simple_server import __main__ as srv_main
|
from pysim_simple_server import __main__ as srv_main
|
||||||
from pysim_simple_server.server import _LineFilter
|
from pysim_simple_server.server import _LineFilter
|
||||||
|
|
||||||
CARDLESS_LINE = 'INIT: no card in the reader — server ready; insert a card or press Equip'
|
CARDLESS_LINE = 'INIT: no card in the reader - server ready; insert a card or press Equip'
|
||||||
|
|
||||||
|
|
||||||
class LineFilterTest(unittest.TestCase):
|
class LineFilterTest(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user