Skip to content

Commit 85503d9

Browse files
committed
Translate OpenSSL SysCallError to ConnectionError
PyOpenSSL raises SysCallError which cheroot does not translate, leading to unhandled exceptions when the underlying connection is closed or shut down abruptly. This commit ensures all relevant errno codes are translated into standard Python ConnectionError types for reliable handling by consumers.
1 parent 4a8dc43 commit 85503d9

File tree

5 files changed

+235
-88
lines changed

5 files changed

+235
-88
lines changed

.flake8

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,10 @@ per-file-ignores =
129129
cheroot/makefile.py: DAR101, DAR201, DAR401, E800, I003, I004, N801, N802, S101, WPS100, WPS110, WPS111, WPS117, WPS120, WPS121, WPS122, WPS123, WPS130, WPS204, WPS210, WPS212, WPS213, WPS220, WPS229, WPS231, WPS232, WPS338, WPS420, WPS422, WPS429, WPS431, WPS504, WPS604, WPS606
130130
cheroot/server.py: DAR003, DAR101, DAR201, DAR202, DAR301, DAR401, E800, I001, I003, I004, I005, N806, RST201, RST301, RST303, RST304, WPS100, WPS110, WPS111, WPS115, WPS120, WPS121, WPS122, WPS130, WPS132, WPS201, WPS202, WPS204, WPS210, WPS211, WPS212, WPS213, WPS214, WPS220, WPS221, WPS225, WPS226, WPS229, WPS230, WPS231, WPS236, WPS237, WPS238, WPS301, WPS338, WPS342, WPS410, WPS420, WPS421, WPS422, WPS429, WPS432, WPS504, WPS505, WPS601, WPS602, WPS608, WPS617
131131
cheroot/ssl/builtin.py: DAR101, DAR201, DAR401, I001, I003, N806, RST304, WPS110, WPS111, WPS115, WPS117, WPS120, WPS121, WPS122, WPS130, WPS201, WPS210, WPS214, WPS229, WPS231, WPS338, WPS422, WPS501, WPS505, WPS529, WPS608, WPS612
132-
cheroot/ssl/pyopenssl.py: C815, DAR101, DAR201, DAR401, I001, I003, I005, N801, N804, RST304, WPS100, WPS110, WPS111, WPS117, WPS120, WPS121, WPS130, WPS210, WPS220, WPS221, WPS225, WPS229, WPS231, WPS238, WPS301, WPS335, WPS338, WPS420, WPS422, WPS430, WPS432, WPS501, WPS504, WPS505, WPS601, WPS608, WPS615
132+
cheroot/ssl/pyopenssl.py: C815, DAR101, DAR201, DAR401, I001, I003, I005, N801, N804, RST304, WPS100, WPS110, WPS111, WPS117, WPS120, WPS121, WPS130, WPS210, WPS220, WPS221, WPS225, WPS229, WPS231, WPS238, WPS301, WPS335, WPS338, WPS420, WPS422, WPS430, WPS432, WPS501, WPS504, WPS505, WPS601, WPS602, WPS608, WPS615
133133
cheroot/test/conftest.py: DAR101, DAR201, DAR301, I001, I003, I005, WPS100, WPS130, WPS325, WPS354, WPS420, WPS422, WPS430, WPS457
134134
cheroot/test/helper.py: DAR101, DAR201, DAR401, I001, I003, I004, N802, WPS110, WPS111, WPS121, WPS201, WPS220, WPS231, WPS301, WPS414, WPS421, WPS422, WPS505
135+
cheroot/test/ssl/test_ssl_pyopenssl.py: DAR101, DAR201, WPS226
135136
cheroot/test/test_cli.py: DAR101, DAR201, I001, I005, N802, S101, S108, WPS110, WPS421, WPS431, WPS473
136137
cheroot/test/test_makefile.py: DAR101, DAR201, I004, RST304, S101, WPS110, WPS122
137138
cheroot/test/test_wsgi.py: DAR101, DAR301, I001, I004, S101, WPS110, WPS111, WPS117, WPS118, WPS121, WPS210, WPS421, WPS430, WPS432, WPS441, WPS509

cheroot/ssl/pyopenssl.py

Lines changed: 120 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
pyopenssl
5151
"""
5252

53+
import errno
54+
import os
5355
import socket
5456
import sys
5557
import threading
@@ -77,6 +79,45 @@
7779
from . import Adapter
7880

7981

82+
@contextlib.contextmanager
83+
def _morph_syscall_to_connection_error(method_name, /):
84+
"""
85+
Handle :exc:`OpenSSL.SSL.SysCallError` in a wrapped method.
86+
87+
This context manager catches and re-raises SSL system call errors
88+
with appropriate exception types.
89+
90+
Yields:
91+
None: Execution continues within the context block.
92+
""" # noqa: DAR301
93+
try:
94+
yield
95+
except SSL.SysCallError as ssl_syscall_err:
96+
connection_error_map = {
97+
errno.EBADF: ConnectionError, # socket is gone?
98+
errno.ECONNABORTED: ConnectionAbortedError,
99+
errno.ECONNREFUSED: ConnectionRefusedError,
100+
errno.ECONNRESET: ConnectionResetError,
101+
errno.ENOTCONN: ConnectionError,
102+
errno.EPIPE: BrokenPipeError,
103+
errno.ESHUTDOWN: BrokenPipeError,
104+
}
105+
error_code = ssl_syscall_err.args[0] if ssl_syscall_err.args else None
106+
error_msg = (
107+
os.strerror(error_code)
108+
if error_code is not None
109+
else repr(ssl_syscall_err)
110+
)
111+
conn_err_cls = connection_error_map.get(
112+
error_code,
113+
ConnectionError,
114+
)
115+
raise conn_err_cls(
116+
error_code,
117+
f'Error in calling {method_name!s} on PyOpenSSL connection: {error_msg!s}',
118+
) from ssl_syscall_err
119+
120+
80121
class SSLFileobjectMixin:
81122
"""Base mixin for a TLS socket stream."""
82123

@@ -175,94 +216,89 @@ class SSLFileobjectStreamWriter(SSLFileobjectMixin, StreamWriter):
175216
"""SSL file object attached to a socket object."""
176217

177218

178-
class SSLConnectionProxyMeta:
179-
"""Metaclass for generating a bunch of proxy methods."""
180-
181-
def __new__(mcl, name, bases, nmspc):
182-
"""Attach a list of proxy methods to a new class."""
183-
proxy_methods = (
184-
'get_context',
185-
'pending',
186-
'send',
187-
'write',
188-
'recv',
189-
'read',
190-
'renegotiate',
191-
'bind',
192-
'listen',
193-
'connect',
194-
'accept',
195-
'setblocking',
196-
'fileno',
197-
'close',
198-
'get_cipher_list',
199-
'getpeername',
200-
'getsockname',
201-
'getsockopt',
202-
'setsockopt',
203-
'makefile',
204-
'get_app_data',
205-
'set_app_data',
206-
'state_string',
207-
'sock_shutdown',
208-
'get_peer_certificate',
209-
'want_read',
210-
'want_write',
211-
'set_connect_state',
212-
'set_accept_state',
213-
'connect_ex',
214-
'sendall',
215-
'settimeout',
216-
'gettimeout',
217-
'shutdown',
218-
)
219-
proxy_methods_no_args = ('shutdown',)
220-
221-
proxy_props = ('family',)
222-
223-
def lock_decorator(method):
224-
"""Create a proxy method for a new class."""
225-
226-
def proxy_wrapper(self, *args):
227-
self._lock.acquire()
228-
try:
229-
new_args = (
230-
args[:] if method not in proxy_methods_no_args else []
231-
)
232-
return getattr(self._ssl_conn, method)(*new_args)
233-
finally:
234-
self._lock.release()
235-
236-
return proxy_wrapper
237-
238-
for m in proxy_methods:
239-
nmspc[m] = lock_decorator(m)
240-
nmspc[m].__name__ = m
241-
242-
def make_property(property_):
243-
"""Create a proxy method for a new class."""
244-
245-
def proxy_prop_wrapper(self):
246-
return getattr(self._ssl_conn, property_)
247-
248-
proxy_prop_wrapper.__name__ = property_
249-
return property(proxy_prop_wrapper)
250-
251-
for p in proxy_props:
252-
nmspc[p] = make_property(p)
219+
# Wrapper function to dynamically add thread-safe methods and properties
220+
def _wrap_ssl_connection_methods(cls):
221+
"""Dynamically attaches proxied methods and properties to the class."""
222+
# 1. Attach Methods
223+
for method in cls.proxy_methods:
224+
wrapped_func = cls._lock_decorator(method)
225+
setattr(cls, method, wrapped_func)
226+
227+
# 2. Attach Properties
228+
for prop in cls.proxy_props:
229+
setattr(cls, prop, cls._make_property(prop))
230+
231+
return cls
232+
233+
234+
@_wrap_ssl_connection_methods
235+
class SSLConnection:
236+
"""A thread-safe wrapper around :py:class:`SSL.Connection <pyopenssl:OpenSSL.SSL.Connection>`."""
237+
238+
proxy_methods = (
239+
'get_context',
240+
'pending',
241+
'send',
242+
'write',
243+
'recv',
244+
'read',
245+
'renegotiate',
246+
'bind',
247+
'listen',
248+
'connect',
249+
'accept',
250+
'setblocking',
251+
'fileno',
252+
'close',
253+
'get_cipher_list',
254+
'getpeername',
255+
'getsockname',
256+
'getsockopt',
257+
'setsockopt',
258+
'makefile',
259+
'get_app_data',
260+
'set_app_data',
261+
'state_string',
262+
'sock_shutdown',
263+
'get_peer_certificate',
264+
'want_read',
265+
'want_write',
266+
'set_connect_state',
267+
'set_accept_state',
268+
'connect_ex',
269+
'sendall',
270+
'settimeout',
271+
'gettimeout',
272+
'shutdown',
273+
)
274+
proxy_methods_no_args = ('shutdown',)
275+
proxy_props = ('family',)
276+
277+
@staticmethod
278+
def _lock_decorator(method):
279+
"""Create the thread-safe, error-translating proxy method."""
280+
281+
def proxy_wrapper(self, *args):
282+
new_args = (
283+
args[:]
284+
if method not in SSLConnection.proxy_methods_no_args
285+
else []
286+
)
287+
with self._lock, _morph_syscall_to_connection_error(method):
288+
return getattr(self._ssl_conn, method)(*new_args)
253289

254-
# Doesn't work via super() for some reason.
255-
# Falling back to type() instead:
256-
return type(name, bases, nmspc)
290+
proxy_wrapper.__name__ = method
291+
return proxy_wrapper
257292

293+
@staticmethod
294+
def _make_property(property_):
295+
"""Create the property proxy."""
258296

259-
class SSLConnection(metaclass=SSLConnectionProxyMeta):
260-
r"""A thread-safe wrapper for an ``SSL.Connection``.
297+
def proxy_prop_wrapper(self):
298+
return getattr(self._ssl_conn, property_)
261299

262-
:param tuple args: the arguments to create the wrapped \
263-
:py:class:`SSL.Connection(*args) \
264-
<pyopenssl:OpenSSL.SSL.Connection>`
265-
"""
300+
proxy_prop_wrapper.__name__ = property_
301+
return property(proxy_prop_wrapper)
266302

267303
def __init__(self, *args):
268304
"""Initialize SSLConnection instance."""

cheroot/ssl/pyopenssl.pyi

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ class SSLFileobjectMixin:
1818
class SSLFileobjectStreamReader(SSLFileobjectMixin, StreamReader): ... # type:ignore[misc]
1919
class SSLFileobjectStreamWriter(SSLFileobjectMixin, StreamWriter): ... # type:ignore[misc]
2020

21-
class SSLConnectionProxyMeta:
22-
def __new__(mcl, name, bases, nmspc): ...
23-
2421
class SSLConnection:
22+
proxy_methods: tuple[str, ...]
23+
proxy_methods_no_args: tuple[str, ...]
24+
proxy_props: tuple[str, ...]
25+
2526
def __init__(self, *args) -> None: ...
2627

2728
class pyOpenSSLAdapter(Adapter):
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for :py:mod:`cheroot.ssl.pyopenssl` :py:class:`cheroot.ssl.pyopenssl.SSLConnection` wrapper."""
2+
3+
import errno
4+
5+
import pytest
6+
7+
from OpenSSL import SSL
8+
9+
from cheroot.ssl.pyopenssl import SSLConnection
10+
11+
12+
@pytest.fixture
13+
def mock_ssl_context(mocker):
14+
"""Fixture providing a mock instance of :py:class:`OpenSSL.SSL.Context`."""
15+
mock_context = mocker.Mock(spec=SSL.Context)
16+
17+
# Add a mock _context attribute to simulate SSL.Context behavior
18+
mock_context._context = mocker.Mock()
19+
return mock_context
20+
21+
22+
@pytest.mark.filterwarnings('ignore:Non-callable called')
23+
@pytest.mark.parametrize(
24+
(
25+
'tested_method_name',
26+
'simulated_errno',
27+
'expected_exception',
28+
),
29+
(
30+
pytest.param('close', errno.EBADF, ConnectionError, id='close-EBADF'),
31+
pytest.param(
32+
'close',
33+
errno.ECONNABORTED,
34+
ConnectionAbortedError,
35+
id='close-ECONNABORTED',
36+
),
37+
pytest.param(
38+
'send',
39+
errno.EPIPE,
40+
BrokenPipeError,
41+
id='send-EPIPE',
42+
), # Expanded coverage
43+
pytest.param(
44+
'shutdown',
45+
errno.EPIPE,
46+
BrokenPipeError,
47+
id='shutdown-EPIPE',
48+
),
49+
pytest.param(
50+
'shutdown',
51+
errno.ECONNRESET,
52+
ConnectionResetError,
53+
id='shutdown-ECONNRESET',
54+
),
55+
pytest.param(
56+
'close',
57+
errno.ENOTCONN,
58+
ConnectionError,
59+
id='close-ENOTCONN',
60+
),
61+
pytest.param('close', errno.EPIPE, BrokenPipeError, id='close-EPIPE'),
62+
pytest.param(
63+
'close',
64+
errno.ESHUTDOWN,
65+
BrokenPipeError,
66+
id='close-ESHUTDOWN',
67+
),
68+
),
69+
)
70+
def test_close_morphs_syscall_error_correctly(
71+
mocker,
72+
mock_ssl_context,
73+
tested_method_name,
74+
simulated_errno,
75+
expected_exception,
76+
):
77+
"""Check ``SSLConnection.close()`` morphs ``SysCallError`` to ``ConnectionError``."""
78+
# Prevents the real OpenSSL.SSL.Connection.__init__ from running
79+
mocker.patch('OpenSSL.SSL.Connection')
80+
81+
# Create SSLConnection object. The patched SSL.Connection() call returns
82+
# a mock that is stored internally as conn._ssl_conn.
83+
conn = SSLConnection(mock_ssl_context)
84+
85+
# Define specific OpenSSL error based on the parameter
86+
simulated_error = SSL.SysCallError(
87+
simulated_errno,
88+
'Simulated connection error',
89+
)
90+
91+
# Dynamically retrieve the method on the underlying mock
92+
underlying_method = getattr(conn._ssl_conn, tested_method_name)
93+
94+
# Patch the method to raise the simulated error
95+
underlying_method.side_effect = simulated_error
96+
97+
expected_match = (
98+
f'.*Error in calling {tested_method_name} on PyOpenSSL connection.*'
99+
)
100+
101+
# Assert the expected exception is raised based on the parameter
102+
with pytest.raises(expected_exception, match=expected_match) as excinfo:
103+
getattr(conn, tested_method_name)()
104+
105+
# Assert the original SysCallError is included in the new exception's cause
106+
assert excinfo.value.__cause__ is simulated_error
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
OpenSSL system call errors are now intercepted and re-raised as Python exceptions derived from :exc:`ConnectionError`.
2+
3+
-- by :user:`julianz-`

0 commit comments

Comments
 (0)