From 0e6fbe561f9b9f8abb07514c7efaec1e9392bdbc Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 6 Jun 2024 11:55:35 +1000 Subject: [PATCH] Add WSManFaultError Adds the new exception WSManFaultError which inherits from WinRMError and will be raised when receiving a WSManFault from the server. This new exception type contains detailed information that could be relevant to the caller when trying to handle the specific exception. --- CHANGELOG.md | 4 + winrm/exceptions.py | 58 ++++++++ winrm/protocol.py | 78 ++++++---- winrm/tests/test_exceptions.py | 256 +++++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 28 deletions(-) create mode 100644 winrm/tests/test_exceptions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf53f4..4186b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ### Version 0.5.0 - Dropped Python 2.7, 3.6, and 3.7 support, minimum supported version is 3.8 - Migrate to PEP 517 compliant build with a `pyproject.toml` file +- Added type annotation +- Added `WSManFaultError` which contains WSManFault specific information when receiving a 500 WSMan fault response + - This contains pre-parsed values like the code, subcode, wsman fault code, wmi error code, and raw response + - It can be used by the caller to implement fallback behaviour based on specific error codes ### Version 0.4.3 - Fix invalid regex escape sequences. diff --git a/winrm/exceptions.py b/winrm/exceptions.py index 6282b20..db4f8a0 100644 --- a/winrm/exceptions.py +++ b/winrm/exceptions.py @@ -7,6 +7,64 @@ class WinRMError(Exception): code = 500 +class WSManFaultError(WinRMError): + """WSMan Fault Error. + + Exception that is raised when receiving a WSMan fault message. It + contains the raw response as well as the fault details parsed from the + response. + + The wsman_fault_code is returned by the Microsoft WSMan server rather than + the WSMan protocol error code strings. The wmierror_code can contain more + fatal service error codes returned as a MSFT_WmiError object, for example + quota violations. + + @param int code: The HTTP status code of the response. + @param str message: The error message. + @param str response: The raw WSMan response text. + @param str reason: The WSMan fault reason. + @param string fault_code: The WSMan fault code. + @param string fault_subcode: The WSMan fault subcode. + @param int wsman_fault_code: The MS WSManFault specific code. + @param int wmierror_code: The MS WMI error code. + """ + + def __init__( + self, + code: int, + message: str, + response: str, + reason: str, + fault_code: str | None = None, + fault_subcode: str | None = None, + wsman_fault_code: int | None = None, + wmierror_code: int | None = None, + ) -> None: + self.code = code + self.response = response + self.fault_code = fault_code + self.fault_subcode = fault_subcode + self.reason = reason + self.wsman_fault_code = wsman_fault_code + self.wmierror_code = wmierror_code + + # Using the dict repr is for backwards compatibility. + fault_data = { + "transport_message": message, + "http_status_code": code, + } + if wsman_fault_code is not None: + fault_data["wsmanfault_code"] = wsman_fault_code + + if fault_code is not None: + fault_data["fault_code"] = fault_code + + if fault_subcode is not None: + fault_data["fault_subcode"] = fault_subcode + + super().__init__("{0} (extended fault data: {1})".format(reason, fault_data)) + + class WinRMTransportError(Exception): """WinRM errors specific to transport-level problems (unexpected HTTP error codes, etc)""" diff --git a/winrm/protocol.py b/winrm/protocol.py index d096e29..e955d47 100644 --- a/winrm/protocol.py +++ b/winrm/protocol.py @@ -10,13 +10,19 @@ import xmltodict -from winrm.exceptions import WinRMError, WinRMOperationTimeoutError, WinRMTransportError +from winrm.exceptions import ( + WinRMError, + WinRMOperationTimeoutError, + WinRMTransportError, + WSManFaultError, +) from winrm.transport import Transport xmlns = { "soapenv": "http://www.w3.org/2003/05/soap-envelope", "soapaddr": "http://schemas.xmlsoap.org/ws/2004/08/addressing", "wsmanfault": "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault", + "wmierror": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/MSFT_WmiError", } @@ -247,33 +253,49 @@ def send_message(self, message: str) -> bytes: raise ex fault = root.find("soapenv:Body/soapenv:Fault", xmlns) - if fault is not None: - fault_data = dict(transport_message=ex.message, http_status_code=ex.code) - wsmanfault_code = fault.find("soapenv:Detail/wsmanfault:WSManFault[@Code]", xmlns) - if wsmanfault_code is not None: - fault_data["wsmanfault_code"] = wsmanfault_code.get("Code") - # convert receive timeout code to WinRMOperationTimeoutError - if fault_data["wsmanfault_code"] == "2150858793": - # TODO: this fault code is specific to the Receive operation; convert all op timeouts? - raise WinRMOperationTimeoutError() - - fault_code = fault.find("soapenv:Code/soapenv:Value", xmlns) - if fault_code is not None: - fault_data["fault_code"] = fault_code.text - - fault_subcode = fault.find("soapenv:Code/soapenv:Subcode/soapenv:Value", xmlns) - if fault_subcode is not None: - fault_data["fault_subcode"] = fault_subcode.text - - error_message_node = fault.find("soapenv:Reason/soapenv:Text", xmlns) - if error_message_node is not None: - error_message = error_message_node.text - else: - error_message = "(no error message in fault)" - - raise WinRMError("{0} (extended fault data: {1})".format(error_message, fault_data)) - - raise + if fault is None: + raise + + wsmanfault_code_raw = fault.find("soapenv:Detail/wsmanfault:WSManFault[@Code]", xmlns) + wsmanfault_code: int | None = None + if wsmanfault_code_raw is not None: + wsmanfault_code = int(wsmanfault_code_raw.attrib["Code"]) + + # convert receive timeout code to WinRMOperationTimeoutError + if wsmanfault_code == 2150858793: + # TODO: this fault code is specific to the Receive operation; convert all op timeouts? + raise WinRMOperationTimeoutError() + + fault_code_raw = fault.find("soapenv:Code/soapenv:Value", xmlns) + fault_code: str | None = None + if fault_code_raw is not None and fault_code_raw.text: + fault_code = fault_code_raw.text + + fault_subcode_raw = fault.find("soapenv:Code/soapenv:Subcode/soapenv:Value", xmlns) + fault_subcode: str | None = None + if fault_subcode_raw is not None and fault_subcode_raw.text: + fault_subcode = fault_subcode_raw.text + + error_message_node = fault.find("soapenv:Reason/soapenv:Text", xmlns) + reason: str | None = None + if error_message_node is not None: + reason = error_message_node.text + + wmi_error_code_raw = fault.find("soapenv:Detail/wmierror:MSFT_WmiError/wmierror:error_Code", xmlns) + wmi_error_code: int | None = None + if wmi_error_code_raw is not None and wmi_error_code_raw.text: + wmi_error_code = int(wmi_error_code_raw.text) + + raise WSManFaultError( + code=ex.code, + message=ex.message, + response=ex.response_text, + reason=reason or "(no error message in fault)", + fault_code=fault_code, + fault_subcode=fault_subcode, + wsman_fault_code=wsmanfault_code, + wmierror_code=wmi_error_code, + ) def close_shell(self, shell_id: str, close_session: bool = True) -> None: """ diff --git a/winrm/tests/test_exceptions.py b/winrm/tests/test_exceptions.py new file mode 100644 index 0000000..d251672 --- /dev/null +++ b/winrm/tests/test_exceptions.py @@ -0,0 +1,256 @@ +import pytest + +from winrm.exceptions import ( + WinRMOperationTimeoutError, + WinRMTransportError, + WSManFaultError, +) + + +def raise_exc(exc: Exception) -> None: + raise exc + + +def test_wsman_fault_must_understand(protocol_fake): + xml_text = r""" + + http://schemas.xmlsoap.org/ws/2004/08/addressing/fault + uuid:4DB571F9-F8DE-48FD-872C-2AF08D996249 + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + uuid:eaa98952-3188-458f-b265-b03ace115f20 + + + + + + s:MustUnderstand + + + Test reason. + + + +""" + + protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text)) + + with pytest.raises(WSManFaultError, match="Test reason\\.") as exc: + protocol_fake.open_shell() + + assert isinstance(exc.value, WSManFaultError) + assert exc.value.code == 500 + assert exc.value.response == xml_text + assert exc.value.fault_code == "s:MustUnderstand" + assert exc.value.fault_subcode is None + assert exc.value.wsman_fault_code is None + assert exc.value.wmierror_code is None + + +def test_wsman_fault_no_reason(protocol_fake): + xml_text = r""" + + http://schemas.xmlsoap.org/ws/2004/08/addressing/fault + uuid:4DB571F9-F8DE-48FD-872C-2AF08D996249 + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + uuid:eaa98952-3188-458f-b265-b03ace115f20 + + + + + + s:Unknown + + + +""" + + protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 501, xml_text)) + + with pytest.raises(WSManFaultError, match="no error message in fault") as exc: + protocol_fake.open_shell() + + assert isinstance(exc.value, WSManFaultError) + assert exc.value.code == 501 + assert exc.value.response == xml_text + assert exc.value.fault_code == "s:Unknown" + assert exc.value.fault_subcode is None + assert exc.value.wsman_fault_code is None + assert exc.value.wmierror_code is None + + +def test_wsman_fault_known_fault(protocol_fake): + xml_text = r""" + + http://schemas.dmtf.org/wbem/wsman/1/wsman/fault + uuid:D7C4A9B1-9A18-4048-B346-248D62A6078D + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + uuid:7340FE92-C302-42E5-A337-1918908654F8 + + + + + s:Receiver + + w:TimedOut + + + + The WS-Management service cannot complete the operation within the time specified in OperationTimeout. + + + + The WS-Management service cannot complete the operation within the time specified in OperationTimeout. + + + + +""" + + protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text)) + + with pytest.raises(WinRMOperationTimeoutError): + protocol_fake.open_shell() + + +def test_wsman_fault_with_wsmanfault(protocol_fake): + xml_text = r""" + + http://schemas.dmtf.org/wbem/wsman/1/wsman/fault + uuid:EE71C444-1658-4B3F-916D-54CE43B68BC9 + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + uuid.761ca906-0bf0-41bb-a9d9-4cbbca986aeb + + + + + s:Sender + + w:SchemaValidationError + + + + Reason text. + + + + Detail message. + + + + +""" + + protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text)) + + with pytest.raises(WSManFaultError, match="Reason text\\.") as exc: + protocol_fake.open_shell() + + assert isinstance(exc.value, WSManFaultError) + assert exc.value.code == 500 + assert exc.value.response == xml_text + assert exc.value.fault_code == "s:Sender" + assert exc.value.fault_subcode == "w:SchemaValidationError" + assert exc.value.wsman_fault_code == 0x80338041 + assert exc.value.wmierror_code is None + + +def test_wsman_fault_wmi_error_detail(protocol_fake): + xml_text = r""" + + http://schemas.dmtf.org/wbem/wsman/1/wsman/fault + uuid:A832545B-9F5C-46AA-BB6A-5E4270D5E530 + http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous + + + + + s:Receiver + + w:InternalError + + + + Reason text. + + + + 27 + + + 0 + 0 + WMI Message. + HRESULT 0x803381a6 + + + + 0 + 0 + + 30 + 2150859174 + HRESULT + Windows Error message. + + + + +""" + + protocol_fake.transport.send_message = lambda m: raise_exc(WinRMTransportError("http", 500, xml_text)) + + with pytest.raises(WSManFaultError, match="Reason text\\.") as exc: + protocol_fake.open_shell() + + assert isinstance(exc.value, WSManFaultError) + assert exc.value.code == 500 + assert exc.value.response == xml_text + assert exc.value.fault_code == "s:Receiver" + assert exc.value.fault_subcode == "w:InternalError" + assert exc.value.wsman_fault_code is None + assert exc.value.wmierror_code == 0x803381A6