Add subproblems to errors (#7046) (#9258)

* Add subproblems to errors (#7046)

* Fix can't assign attribute

* Tidy up string representations of errors and add decoders for subproblems / identifiers

* Add missing attributes to docstring

* Move change to 1.27.0 in changelog
This commit is contained in:
James Balazs
2022-04-06 09:34:26 -07:00
committed by GitHub
parent 87216372dd
commit 0fb5094250
4 changed files with 88 additions and 54 deletions
+1
View File
@@ -115,6 +115,7 @@ Authors
* [Jacob Sachs](https://github.com/jsachs) * [Jacob Sachs](https://github.com/jsachs)
* [Jairo Llopis](https://github.com/Yajo) * [Jairo Llopis](https://github.com/Yajo)
* [Jakub Warmuz](https://github.com/kuba) * [Jakub Warmuz](https://github.com/kuba)
* [James Balazs](https://github.com/jamesbalazs)
* [James Kasten](https://github.com/jdkasten) * [James Kasten](https://github.com/jdkasten)
* [Jason Grinblat](https://github.com/ptychomancer) * [Jason Grinblat](https://github.com/ptychomancer)
* [Jay Faulkner](https://github.com/jayofdoom) * [Jay Faulkner](https://github.com/jayofdoom)
+68 -50
View File
@@ -79,6 +79,55 @@ def is_acme_error(err: BaseException) -> bool:
return False return False
class _Constant(jose.JSONDeSerializable, Hashable):
"""ACME constant."""
__slots__ = ('name',)
POSSIBLE_NAMES: Dict[str, '_Constant'] = NotImplemented
def __init__(self, name: str) -> None:
super().__init__()
self.POSSIBLE_NAMES[name] = self # pylint: disable=unsupported-assignment-operation
self.name = name
def to_partial_json(self) -> str:
return self.name
@classmethod
def from_json(cls, jobj: str) -> '_Constant':
if jobj not in cls.POSSIBLE_NAMES: # pylint: disable=unsupported-membership-test
raise jose.DeserializationError(f'{cls.__name__} not recognized')
return cls.POSSIBLE_NAMES[jobj]
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.name})'
def __eq__(self, other: Any) -> bool:
return isinstance(other, type(self)) and other.name == self.name
def __hash__(self) -> int:
return hash((self.__class__, self.name))
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES: Dict[str, _Constant] = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
IDENTIFIER_IP = IdentifierType('ip') # IdentifierIP in pebble - not in Boulder yet
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar IdentifierType typ:
:ivar str value:
"""
typ: IdentifierType = jose.field('type', decoder=IdentifierType.from_json)
value: str = jose.field('value')
class Error(jose.JSONObjectWithFields, errors.Error): class Error(jose.JSONObjectWithFields, errors.Error):
"""ACME error. """ACME error.
@@ -87,11 +136,23 @@ class Error(jose.JSONObjectWithFields, errors.Error):
:ivar str typ: :ivar str typ:
:ivar str title: :ivar str title:
:ivar str detail: :ivar str detail:
:ivar Identifier identifier:
:ivar tuple subproblems: An array of ACME Errors which may be present when the CA
returns multiple errors related to the same request, `tuple` of `Error`.
""" """
typ: str = jose.field('type', omitempty=True, default='about:blank') typ: str = jose.field('type', omitempty=True, default='about:blank')
title: str = jose.field('title', omitempty=True) title: str = jose.field('title', omitempty=True)
detail: str = jose.field('detail', omitempty=True) detail: str = jose.field('detail', omitempty=True)
identifier: Optional['Identifier'] = jose.field(
'identifier', decoder=Identifier.from_json, omitempty=True)
subproblems: Optional[Tuple['Error', ...]] = jose.field('subproblems', omitempty=True)
# Mypy does not understand the josepy magic happening here, and falsely claims
# that subproblems is redefined. Let's ignore the type check here.
@subproblems.decoder # type: ignore
def subproblems(value: List[Dict[str, Any]]) -> Tuple['Error', ...]: # type: ignore[misc] # pylint: disable=no-self-argument,missing-function-docstring
return tuple(Error.from_json(subproblem) for subproblem in value)
@classmethod @classmethod
def with_code(cls, code: str, **kwargs: Any) -> 'Error': def with_code(cls, code: str, **kwargs: Any) -> 'Error':
@@ -135,39 +196,16 @@ class Error(jose.JSONObjectWithFields, errors.Error):
return None return None
def __str__(self) -> str: def __str__(self) -> str:
return b' :: '.join( result = b' :: '.join(
part.encode('ascii', 'backslashreplace') for part in part.encode('ascii', 'backslashreplace') for part in
(self.typ, self.description, self.detail, self.title) (self.typ, self.description, self.detail, self.title)
if part is not None).decode() if part is not None).decode()
if self.identifier:
result = f'Problem for {self.identifier.value}: ' + result # pylint: disable=no-member
class _Constant(jose.JSONDeSerializable, Hashable): if self.subproblems and len(self.subproblems) > 0:
"""ACME constant.""" for subproblem in self.subproblems:
__slots__ = ('name',) result += f'\n{subproblem}'
POSSIBLE_NAMES: Dict[str, '_Constant'] = NotImplemented return result
def __init__(self, name: str) -> None:
super().__init__()
self.POSSIBLE_NAMES[name] = self # pylint: disable=unsupported-assignment-operation
self.name = name
def to_partial_json(self) -> str:
return self.name
@classmethod
def from_json(cls, jobj: str) -> '_Constant':
if jobj not in cls.POSSIBLE_NAMES: # pylint: disable=unsupported-membership-test
raise jose.DeserializationError(f'{cls.__name__} not recognized')
return cls.POSSIBLE_NAMES[jobj]
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.name})'
def __eq__(self, other: Any) -> bool:
return isinstance(other, type(self)) and other.name == self.name
def __hash__(self) -> int:
return hash((self.__class__, self.name))
class Status(_Constant): class Status(_Constant):
@@ -185,26 +223,6 @@ STATUS_READY = Status('ready')
STATUS_DEACTIVATED = Status('deactivated') STATUS_DEACTIVATED = Status('deactivated')
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES: Dict[str, _Constant] = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
IDENTIFIER_IP = IdentifierType('ip') # IdentifierIP in pebble - not in Boulder yet
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar IdentifierType typ:
:ivar str value:
"""
typ: IdentifierType = jose.field('type', decoder=IdentifierType.from_json)
value: str = jose.field('value')
class HasResourceType(Protocol): class HasResourceType(Protocol):
""" """
Represents a class with a resource_type class parameter of type string. Represents a class with a resource_type class parameter of type string.
+17 -2
View File
@@ -17,7 +17,7 @@ class ErrorTest(unittest.TestCase):
"""Tests for acme.messages.Error.""" """Tests for acme.messages.Error."""
def setUp(self): def setUp(self):
from acme.messages import Error, ERROR_PREFIX from acme.messages import Error, ERROR_PREFIX, Identifier, IDENTIFIER_FQDN
self.error = Error.with_code('malformed', detail='foo', title='title') self.error = Error.with_code('malformed', detail='foo', title='title')
self.jobj = { self.jobj = {
'detail': 'foo', 'detail': 'foo',
@@ -25,6 +25,9 @@ class ErrorTest(unittest.TestCase):
'type': ERROR_PREFIX + 'malformed', 'type': ERROR_PREFIX + 'malformed',
} }
self.error_custom = Error(typ='custom', detail='bar') self.error_custom = Error(typ='custom', detail='bar')
self.identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
self.subproblem = Error.with_code('caa', detail='bar', title='title', identifier=self.identifier)
self.error_with_subproblems = Error.with_code('malformed', detail='foo', title='title', subproblems=[self.subproblem])
self.empty_error = Error() self.empty_error = Error()
def test_default_typ(self): def test_default_typ(self):
@@ -39,6 +42,14 @@ class ErrorTest(unittest.TestCase):
from acme.messages import Error from acme.messages import Error
hash(Error.from_json(self.error.to_json())) hash(Error.from_json(self.error.to_json()))
def test_from_json_with_subproblems(self):
from acme.messages import Error
parsed_error = Error.from_json(self.error_with_subproblems.to_json())
self.assertEqual(1, len(parsed_error.subproblems))
self.assertEqual(self.subproblem, parsed_error.subproblems[0])
def test_description(self): def test_description(self):
self.assertEqual('The request message was malformed', self.error.description) self.assertEqual('The request message was malformed', self.error.description)
self.assertIsNone(self.error_custom.description) self.assertIsNone(self.error_custom.description)
@@ -73,7 +84,11 @@ class ErrorTest(unittest.TestCase):
str(self.error), str(self.error),
u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}" u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}"
.format(self.error)) .format(self.error))
self.assertEqual(
str(self.error_with_subproblems),
(u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}\n"+
u"Problem for {1.identifier.value}: {1.typ} :: {1.description} :: {1.detail} :: {1.title}").format(
self.error_with_subproblems, self.subproblem))
class ConstantTest(unittest.TestCase): class ConstantTest(unittest.TestCase):
"""Tests for acme.messages._Constant.""" """Tests for acme.messages._Constant."""
+2 -2
View File
@@ -6,7 +6,7 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added ### Added
* * Added support for RFC8555 subproblems to our acme library.
### Changed ### Changed
@@ -36,7 +36,7 @@ More details about these changes can be found on our GitHub repo.
### Fixed ### Fixed
* Certbot for Windows has been upgraded to use Python 3.9.11, in response to * Certbot for Windows has been upgraded to use Python 3.9.11, in response to
https://www.openssl.org/news/secadv/20220315.txt. https://www.openssl.org/news/secadv/20220315.txt.
* Previously, when Certbot was in the process of registering a new ACME account * Previously, when Certbot was in the process of registering a new ACME account
and the ACME server did not present any Terms of Service, the user was asked to and the ACME server did not present any Terms of Service, the user was asked to
agree with a non-existent Terms of Service ("None"). This bug is now fixed, so agree with a non-existent Terms of Service ("None"). This bug is now fixed, so