Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
011de4ae82 |
No files matched your search
@@ -9,6 +9,26 @@ every change carries a descriptive commit message.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.40.5] — 2026-11-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Server request/access log leaked into captured file content.** The HTTP
|
||||||
|
access log (`log_message`) and the `--log-requests` `REQUEST`/`RESPONSE`
|
||||||
|
lines wrote to the rebindable `sys.stderr`, so a concurrent card-free request
|
||||||
|
(the 2 s `/api/status` poll) dropped its log line into the `StringIO` a
|
||||||
|
card command was capturing — corrupting file content read during a snapshot
|
||||||
|
scan (the log line appeared in the snapshot, e.g.
|
||||||
|
`127.0.0.1 - - […] "GET /api/status HTTP/1.1" 200 -ffff…`). The log now
|
||||||
|
writes to fd 2 via `_log_stderr`, exactly as the APDU tracer already does.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Deduplicated the pySim output capture.** The six handlers that captured
|
||||||
|
pySim command output by rebinding `sys.stdout`/`sys.stderr` to a `StringIO`
|
||||||
|
now share one `_capture_app_output` context manager (the same save/set/
|
||||||
|
restore semantics, in one place).
|
||||||
|
|
||||||
## [3.40.4] — 2026-11-04
|
## [3.40.4] — 2026-11-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1990,7 +1990,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.40.4';
|
const SIMPLE_VERSION = '3.40.5';
|
||||||
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-v490';
|
const CACHE = 'simple-v491';
|
||||||
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.40.4"
|
version = "3.40.5"
|
||||||
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"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from urllib.parse import unquote_plus
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http.server import BaseHTTPRequestHandler
|
from http.server import BaseHTTPRequestHandler
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from contextlib import contextmanager
|
||||||
from pySim.transport import ApduTracer, ProactiveHandler
|
from pySim.transport import ApduTracer, ProactiveHandler
|
||||||
from pySim.cards import UiccCardBase
|
from pySim.cards import UiccCardBase
|
||||||
from pysim_simple_server import httpota
|
from pysim_simple_server import httpota
|
||||||
@@ -37,7 +38,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.40.4'
|
VERSION = '3.40.5'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -267,6 +268,42 @@ def _cmd_exception(output):
|
|||||||
return 'command failed'
|
return 'command failed'
|
||||||
|
|
||||||
|
|
||||||
|
def _log_stderr(msg):
|
||||||
|
"""Write a server log line to the real stderr fd, bypassing ``sys.stderr``.
|
||||||
|
|
||||||
|
The HTTP handlers temporarily rebind the global ``sys.stderr`` to a
|
||||||
|
``StringIO`` to capture pySim command output. Concurrent card-free
|
||||||
|
requests (the 2 s /api/status poll) log via ``log_message`` / ``_log_req``
|
||||||
|
/ ``_log_resp``; writing to ``sys.stderr`` would drop those lines into the
|
||||||
|
active capture buffer and corrupt the file content (the snapshot "access
|
||||||
|
log line in contents" bug). Writing to fd 2 keeps the request log out of
|
||||||
|
the capture, exactly as the APDU tracer already does below."""
|
||||||
|
os.write(2, msg.encode())
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _capture_app_output(app):
|
||||||
|
"""Temporarily capture the pySim app's output into a ``StringIO``.
|
||||||
|
|
||||||
|
cmd2 writes command output to ``app.stdout``; stray writes to the global
|
||||||
|
``sys.stderr`` (``print(file=sys.stderr)``, ``sys.stderr.write``, logging,
|
||||||
|
tracebacks) are captured too by rebinding it. Card access is serialized by
|
||||||
|
``_CARD_LOCK``, so only one capture is active at a time, and the server's
|
||||||
|
own request/access logging bypasses it via ``_log_stderr``. Yields the
|
||||||
|
buffer and restores both streams in ``finally``.
|
||||||
|
"""
|
||||||
|
out = StringIO()
|
||||||
|
old_stdout = app.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
app.stdout = out
|
||||||
|
sys.stderr = out
|
||||||
|
try:
|
||||||
|
yield out
|
||||||
|
finally:
|
||||||
|
app.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
|
||||||
def _parse_help_text(text):
|
def _parse_help_text(text):
|
||||||
result = {'usage': '', 'description': '', 'args': []}
|
result = {'usage': '', 'description': '', 'args': []}
|
||||||
lines = text.split('\n')
|
lines = text.split('\n')
|
||||||
@@ -9472,13 +9509,13 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
def _log_req(self, body=None):
|
def _log_req(self, body=None):
|
||||||
if self.server.log_requests:
|
if self.server.log_requests:
|
||||||
if body is not None:
|
if body is not None:
|
||||||
sys.stderr.write("REQUEST %s %s: %s\n" % (self.command, self.path, json.dumps(body)))
|
_log_stderr("REQUEST %s %s: %s\n" % (self.command, self.path, json.dumps(body)))
|
||||||
else:
|
else:
|
||||||
sys.stderr.write("REQUEST %s %s\n" % (self.command, self.path))
|
_log_stderr("REQUEST %s %s\n" % (self.command, self.path))
|
||||||
|
|
||||||
def _log_resp(self, data):
|
def _log_resp(self, data):
|
||||||
if self.server.log_requests:
|
if self.server.log_requests:
|
||||||
sys.stderr.write("RESPONSE %s: %s\n" % (self.path, json.dumps(data, ensure_ascii=False)))
|
_log_stderr("RESPONSE %s: %s\n" % (self.path, json.dumps(data, ensure_ascii=False)))
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
def do_OPTIONS(self):
|
||||||
self._log_req()
|
self._log_req()
|
||||||
@@ -9777,19 +9814,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json({'error': _err('app_not_init', lang)}, 503)
|
self._send_json({'error': _err('app_not_init', lang)}, 503)
|
||||||
self._log_resp({'error': _err('app_not_init', lang)})
|
self._log_resp({'error': _err('app_not_init', lang)})
|
||||||
return
|
return
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
try:
|
||||||
app.onecmd_plus_hooks('cardinfo')
|
app.onecmd_plus_hooks('cardinfo')
|
||||||
output = _strip_ansi(out.getvalue())
|
output = _strip_ansi(out.getvalue())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
output = str(e) + '\n' + traceback.format_exc()
|
output = str(e) + '\n' + traceback.format_exc()
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
resp = {'output': output}
|
resp = {'output': output}
|
||||||
self._send_json(resp)
|
self._send_json(resp)
|
||||||
self._log_resp(resp)
|
self._log_resp(resp)
|
||||||
@@ -9931,19 +9961,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
# transport so a manual Equip recovers too.
|
# transport so a manual Equip recovers too.
|
||||||
_ensure_transport(self.server)
|
_ensure_transport(self.server)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
try:
|
||||||
stop = app.onecmd_plus_hooks(cmd)
|
stop = app.onecmd_plus_hooks(cmd)
|
||||||
output = _strip_ansi(out.getvalue())
|
output = _strip_ansi(out.getvalue())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
output = str(e) + '\n' + traceback.format_exc()
|
output = str(e) + '\n' + traceback.format_exc()
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
elapsed = int((time.time() - t0) * 1000)
|
elapsed = int((time.time() - t0) * 1000)
|
||||||
status = 'OK' if not output or 'not a recognized command' not in output else 'ERROR'
|
status = 'OK' if not output or 'not a recognized command' not in output else 'ERROR'
|
||||||
if is_equip:
|
if is_equip:
|
||||||
@@ -10199,17 +10222,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
body = self._read_body()
|
body = self._read_body()
|
||||||
self._log_req(body)
|
self._log_req(body)
|
||||||
cmd = body.get('cmd', '')
|
cmd = body.get('cmd', '')
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
|
||||||
app.onecmd_plus_hooks('help ' + cmd)
|
app.onecmd_plus_hooks('help ' + cmd)
|
||||||
raw = out.getvalue()
|
raw = out.getvalue()
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
clean = _strip_ansi(raw)
|
clean = _strip_ansi(raw)
|
||||||
parsed = _parse_help_text(clean)
|
parsed = _parse_help_text(clean)
|
||||||
self._send_json(parsed)
|
self._send_json(parsed)
|
||||||
@@ -10307,17 +10322,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
|
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
|
||||||
else:
|
else:
|
||||||
cmd = 'read_records' if is_record else 'read_binary'
|
cmd = 'read_records' if is_record else 'read_binary'
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
|
||||||
app.onecmd_plus_hooks(cmd)
|
app.onecmd_plus_hooks(cmd)
|
||||||
output = _strip_ansi(out.getvalue())
|
output = _strip_ansi(out.getvalue())
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
finally:
|
finally:
|
||||||
apdu_times = _end_apdu_time_collection()
|
apdu_times = _end_apdu_time_collection()
|
||||||
exc = _cmd_exception(output)
|
exc = _cmd_exception(output)
|
||||||
@@ -10403,17 +10410,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
cmd = 'update_record 1 %s' % data
|
cmd = 'update_record 1 %s' % data
|
||||||
else:
|
else:
|
||||||
cmd = 'update_binary %s' % data
|
cmd = 'update_binary %s' % data
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
|
||||||
app.onecmd_plus_hooks(cmd)
|
app.onecmd_plus_hooks(cmd)
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
exc = _cmd_exception(output)
|
exc = _cmd_exception(output)
|
||||||
if exc:
|
if exc:
|
||||||
resp = {'success': False, 'error': exc}
|
resp = {'success': False, 'error': exc}
|
||||||
@@ -10472,17 +10471,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
cur = lchan.selected_file
|
cur = lchan.selected_file
|
||||||
out = StringIO()
|
with _capture_app_output(app) as out:
|
||||||
old_stdout = app.stdout
|
|
||||||
old_stderr = sys.stderr
|
|
||||||
app.stdout = out
|
|
||||||
sys.stderr = out
|
|
||||||
try:
|
|
||||||
app.onecmd_plus_hooks('tree')
|
app.onecmd_plus_hooks('tree')
|
||||||
output = _strip_ansi(out.getvalue())
|
output = _strip_ansi(out.getvalue())
|
||||||
finally:
|
|
||||||
app.stdout = old_stdout
|
|
||||||
sys.stderr = old_stderr
|
|
||||||
children = _parse_tree_output(output)
|
children = _parse_tree_output(output)
|
||||||
# EF.PBR (TS 31.102 4.4.2.1): the phonebook files the DF's PBR
|
# EF.PBR (TS 31.102 4.4.2.1): the phonebook files the DF's PBR
|
||||||
# references are not in pySim's model, so they are appended to
|
# references are not in pySim's model, so they are appended to
|
||||||
@@ -12178,4 +12169,4 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._log_resp({'error': _err('not_found', lang)})
|
self._log_resp({'error': _err('not_found', lang)})
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
_log_stderr('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""The request/access log must never enter a pySim output capture.
|
||||||
|
|
||||||
|
Handlers capture pySim output by temporarily rebinding the global ``sys.stderr``
|
||||||
|
to a StringIO (``_capture_app_output``). Concurrent card-free requests (the 2 s
|
||||||
|
/api/status poll) log via ``log_message`` / ``_log_req`` / ``_log_resp``; if
|
||||||
|
those wrote to ``sys.stderr`` their lines would land in the active capture
|
||||||
|
buffer and corrupt the file content (the snapshot "access log line in contents"
|
||||||
|
bug). The log now writes to fd 2 directly, and the capture is a context
|
||||||
|
manager that restores both streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pysim_simple_server import server as srv
|
||||||
|
from pysim_simple_server.server import PysimHandler, _capture_app_output, _log_stderr
|
||||||
|
|
||||||
|
|
||||||
|
class LogStderrTests(unittest.TestCase):
|
||||||
|
def test_log_stderr_writes_to_fd2_not_the_rebindable_stream(self):
|
||||||
|
writes = []
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch.object(srv.os, 'write', side_effect=lambda fd, data: writes.append((fd, data))), \
|
||||||
|
mock.patch('sys.stderr', new=err):
|
||||||
|
_log_stderr('hello\n')
|
||||||
|
self.assertEqual(writes, [(2, b'hello\n')])
|
||||||
|
self.assertEqual(err.getvalue(), '')
|
||||||
|
|
||||||
|
def test_log_message_goes_to_fd2(self):
|
||||||
|
h = object.__new__(PysimHandler)
|
||||||
|
h.client_address = ('127.0.0.1', 1234)
|
||||||
|
h.log_date_time_string = lambda: '05/Nov/2026 14:09:10'
|
||||||
|
writes = []
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch.object(srv.os, 'write', side_effect=lambda fd, data: writes.append((fd, data))), \
|
||||||
|
mock.patch('sys.stderr', new=err):
|
||||||
|
h.log_message('"%s" %s %s', 'GET /api/status HTTP/1.1', '200', '-')
|
||||||
|
self.assertEqual(
|
||||||
|
writes,
|
||||||
|
[(2, b'127.0.0.1 - - [05/Nov/2026 14:09:10] '
|
||||||
|
b'"GET /api/status HTTP/1.1" 200 -\n')])
|
||||||
|
self.assertEqual(err.getvalue(), '',
|
||||||
|
'the access log must not land in a redirected sys.stderr')
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureOutputTests(unittest.TestCase):
|
||||||
|
def test_captures_both_streams_and_restores(self):
|
||||||
|
orig_stdout = io.StringIO()
|
||||||
|
app = types.SimpleNamespace(stdout=orig_stdout)
|
||||||
|
real_stderr = sys.stderr
|
||||||
|
with _capture_app_output(app) as out:
|
||||||
|
app.stdout.write('AABB\n')
|
||||||
|
sys.stderr.write('SW: 9000\n')
|
||||||
|
self.assertIn('AABB\n', out.getvalue())
|
||||||
|
self.assertIn('SW: 9000\n', out.getvalue())
|
||||||
|
self.assertIs(app.stdout, orig_stdout)
|
||||||
|
self.assertIs(sys.stderr, real_stderr)
|
||||||
|
|
||||||
|
def test_restores_on_exception(self):
|
||||||
|
orig_stdout = io.StringIO()
|
||||||
|
app = types.SimpleNamespace(stdout=orig_stdout)
|
||||||
|
real_stderr = sys.stderr
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
with _capture_app_output(app) as out:
|
||||||
|
app.stdout.write('partial\n')
|
||||||
|
raise RuntimeError('boom')
|
||||||
|
self.assertIs(app.stdout, orig_stdout)
|
||||||
|
self.assertIs(sys.stderr, real_stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class SourcePinTests(unittest.TestCase):
|
||||||
|
def test_log_message_uses_fd2(self):
|
||||||
|
src = pathlib.Path(srv.__file__).read_text()
|
||||||
|
seg = src[src.index('def log_message('):]
|
||||||
|
self.assertIn('_log_stderr(', seg)
|
||||||
|
self.assertNotIn('sys.stderr.write', seg)
|
||||||
|
|
||||||
|
def test_the_handlers_use_the_capture_context_manager(self):
|
||||||
|
# The six handlers that capture pySim output share one context manager;
|
||||||
|
# a re-introduced inline ``sys.stderr = out`` block would be a
|
||||||
|
# thread-safety regression.
|
||||||
|
src = pathlib.Path(srv.__file__).read_text()
|
||||||
|
self.assertGreaterEqual(src.count('with _capture_app_output(app) as out:'), 6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user