Skip to content

Commit b594cd5

Browse files
committed
Map PyOpenSSL SysCallErrors to standard Python ConnectionErrors
1 parent 4a8dc43 commit b594cd5

File tree

3 files changed

+107
-1
lines changed

3 files changed

+107
-1
lines changed

cheroot/ssl/pyopenssl.py

Lines changed: 48 additions & 1 deletion
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
@@ -76,6 +78,49 @@
7678
from ..makefile import StreamReader, StreamWriter
7779
from . import Adapter
7880

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

80125
class SSLFileobjectMixin:
81126
"""Base mixin for a TLS socket stream."""
@@ -229,7 +274,9 @@ def proxy_wrapper(self, *args):
229274
new_args = (
230275
args[:] if method not in proxy_methods_no_args else []
231276
)
232-
return getattr(self._ssl_conn, method)(*new_args)
277+
# translate any SysCallError to ConnectionError
278+
with _morph_syscall_to_connection_error(method):
279+
return getattr(self._ssl_conn, method)(*new_args)
233280
finally:
234281
self._lock.release()
235282

cheroot/test/test_ssl_pyopenssl.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Assuming your existing test file has fixtures for the SSL objects
2+
import pytest
3+
import errno
4+
from OpenSSL import SSL
5+
from cheroot.ssl.pyopenssl import SSLConnection
6+
7+
@pytest.fixture
8+
def mock_ssl_context(mocker):
9+
"""Fixture providing a mock instance of SSL.Context."""
10+
return mocker.Mock(spec=SSL.Context)
11+
12+
@pytest.fixture
13+
def mock_ssl_conn_class(mocker):
14+
"""Fixture patching the SSL.Connection class constructor."""
15+
return mocker.patch('OpenSSL.SSL.Connection')
16+
17+
# (SysCallError errno, Expected Exception Type)
18+
ERROR_MAPPINGS = [
19+
(errno.EBADF, ConnectionError),
20+
(errno.ECONNABORTED, ConnectionAbortedError),
21+
(errno.ECONNREFUSED, ConnectionRefusedError),
22+
(errno.ECONNRESET, ConnectionResetError),
23+
(errno.ENOTCONN, ConnectionError),
24+
(errno.EPIPE, BrokenPipeError),
25+
(errno.ESHUTDOWN, BrokenPipeError),
26+
]
27+
28+
@pytest.mark.parametrize(
29+
'simulated_errno, expected_exception', ERROR_MAPPINGS
30+
)
31+
def test_close_morphs_syscall_error_correctly(
32+
mocker, mock_ssl_context, mock_ssl_conn_class,
33+
simulated_errno, expected_exception
34+
):
35+
# The SSLConnection object will now have a safe mock for self._ssl_conn
36+
conn = SSLConnection(mock_ssl_context)
37+
38+
# Define the specific OpenSSL error based on the parameter
39+
simulated_error = SSL.SysCallError(simulated_errno, 'Simulated connection error')
40+
41+
# 4. Patch the 'close' method on the underlying MOCK object.
42+
# We retrieve the mock object that was placed in conn._ssl_conn during init.
43+
# The return value of the mocked SSL.Connection class is what conn._ssl_conn holds.
44+
underlying_mock = conn._ssl_conn
45+
46+
mocker.patch.object(
47+
underlying_mock, 'close',
48+
side_effect=simulated_error
49+
)
50+
51+
# Assert the expected exception is raised based on the parameter
52+
with pytest.raises(expected_exception) as excinfo:
53+
conn.close()
54+
55+
# 6. Assert the original SysCallError is included in the new exception's cause
56+
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 reraised as standard Python ConnectionErrors.
2+
3+
-- by :user:`julianz-`

0 commit comments

Comments
 (0)