diff --git a/cyclonedx/validation/__init__.py b/cyclonedx/validation/__init__.py index 7ff3b8823..fad11f783 100644 --- a/cyclonedx/validation/__init__.py +++ b/cyclonedx/validation/__init__.py @@ -29,22 +29,28 @@ class ValidationError: - """Validation failed with this specific error. - - Use :attr:`~data` to access the content. - """ + """Validation failed with this specific error.""" data: Any """Raw error data from one of the underlying validation methods.""" - def __init__(self, data: Any) -> None: + message: str + """Human-readable error message suitable for end-user presentation.""" + + path: tuple[Union[str, int], ...] + """Path to the offending value in the document, if known.""" + + def __init__(self, data: Any, *, message: Optional[str] = None, + path: Iterable[Union[str, int]] = ()) -> None: self.data = data + self.message = str(data) if message is None else message + self.path = tuple(path) def __repr__(self) -> str: - return repr(self.data) + return f'{self.__class__.__name__}(message={self.message!r}, path={self.path!r})' def __str__(self) -> str: - return str(self.data) + return self.message class SchemabasedValidator(Protocol): diff --git a/cyclonedx/validation/json.py b/cyclonedx/validation/json.py index 6431df57a..b7261b888 100644 --- a/cyclonedx/validation/json.py +++ b/cyclonedx/validation/json.py @@ -55,12 +55,51 @@ ), err +_MAX_MESSAGE_LEN = 256 # chars to keep from a message before truncating + + +def _shorten_message(message: str, instance: object) -> str: + """Return a human-readable, bounded error message. + + Two sources of bloat are addressed: + 1. jsonschema embeds ``repr(instance)`` verbatim — for large SBOM + documents (e.g. a ``uniqueItems`` failure on ``dependencies``) this + alone can be hundreds of kilobytes. + 2. ``enum`` keywords include the full allowed-values list in the message + (e.g. the ~800-entry SPDX licence ID list). + + Strategy: replace a long ``repr(instance)`` first, then hard-cap the + whole message and append ``…`` if it still exceeds ``_MAX_MESSAGE_LEN``. + """ + full_repr = repr(instance) + if len(full_repr) > _MAX_MESSAGE_LEN // 2: + half = _MAX_MESSAGE_LEN // 4 + short_repr = f'{full_repr[:half]}...{full_repr[-half:]}' + message = message.replace(full_repr, short_repr, 1) + if len(message) > _MAX_MESSAGE_LEN: + message = message[:_MAX_MESSAGE_LEN] + '…' + return message + + class JsonValidationError(ValidationError): + @classmethod + def __get_most_relevant_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonSchemaValidationError': + if not e.context: + return e + # nested `context` errors generally provide more useful details than + # the generic parent message (e.g. for oneOf/anyOf checks). + child = max(e.context, key=lambda ce: len(ce.absolute_path)) + return cls.__get_most_relevant_jsve(child) + @classmethod def _make_from_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonValidationError': """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" - # in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836 - return cls(e) + useful = cls.__get_most_relevant_jsve(e) + return cls( + e, + message=_shorten_message(useful.message, useful.instance), + path=tuple(useful.absolute_path) + ) class _BaseJsonValidator(BaseSchemabasedValidator, ABC): diff --git a/cyclonedx/validation/xml.py b/cyclonedx/validation/xml.py index 14f528088..3ef8b799a 100644 --- a/cyclonedx/validation/xml.py +++ b/cyclonedx/validation/xml.py @@ -47,12 +47,27 @@ ), err +_MAX_MESSAGE_LEN = 256 # chars to keep from a message before truncating + + +def _shorten_xml_message(message: str) -> str: + """Cap an lxml error message at ``_MAX_MESSAGE_LEN`` characters. + + lxml embeds the full enumeration set in ``[facet 'enumeration']`` messages + (e.g. the ~800-entry SPDX licence ID list), producing multi-kilobyte + single-line strings. We hard-cap and append ``…`` so callers can safely + log or display the message. + """ + if len(message) > _MAX_MESSAGE_LEN: + return message[:_MAX_MESSAGE_LEN] + '…' + return message + + class XmlValidationError(ValidationError): @classmethod def _make_from_xle(cls, e: '_XmlLogEntry') -> 'XmlValidationError': """⚠️ This is an internal API. It is not part of the public interface and may change without notice.""" - # in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836 - return cls(e) + return cls(e, message=_shorten_xml_message(e.message), path=(e.path,) if e.path else ()) class _BaseXmlValidator(BaseSchemabasedValidator, ABC): diff --git a/tests/test_validation_json.py b/tests/test_validation_json.py index 48cfa1b68..fed5131a1 100644 --- a/tests/test_validation_json.py +++ b/tests/test_validation_json.py @@ -25,7 +25,7 @@ from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.schema import OutputFormat, SchemaVersion -from cyclonedx.validation.json import JsonStrictValidator, JsonValidator +from cyclonedx.validation.json import JsonStrictValidator, JsonValidationError, JsonValidator from tests import OWN_DATA_DIRECTORY, SCHEMA_TESTDATA_DIRECTORY, DpTuple UNSUPPORTED_SCHEMA_VERSIONS = {SchemaVersion.V1_0, SchemaVersion.V1_1, } @@ -92,6 +92,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -111,6 +116,11 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t self.assertGreater(len(validation_errors), 0) for validation_error in validation_errors: self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @ddt @@ -151,6 +161,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -170,3 +185,8 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t self.assertGreater(len(validation_errors), 0) for validation_error in validation_errors: self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, JsonValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') diff --git a/tests/test_validation_xml.py b/tests/test_validation_xml.py index 2565a85ad..ff424be47 100644 --- a/tests/test_validation_xml.py +++ b/tests/test_validation_xml.py @@ -25,7 +25,7 @@ from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.schema import OutputFormat, SchemaVersion -from cyclonedx.validation.xml import XmlValidator +from cyclonedx.validation.xml import XmlValidationError, XmlValidator from tests import OWN_DATA_DIRECTORY, SCHEMA_TESTDATA_DIRECTORY, DpTuple UNSUPPORTED_SCHEMA_VERSIONS = set() @@ -92,6 +92,11 @@ def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_d self.skipTest('MissingOptionalDependencyException') self.assertIsNotNone(validation_error) self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, XmlValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)') @idata(chain( _dp_sv_tf(False), @@ -111,3 +116,8 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t self.assertGreater(len(validation_errors), 0) for validation_error in validation_errors: self.assertIsNotNone(validation_error.data) + self.assertIsInstance(validation_error, XmlValidationError) + self.assertIsInstance(validation_error.message, str) + self.assertIsInstance(validation_error.path, tuple) + self.assertLessEqual(len(validation_error.message), 257, + 'message must be bounded (≤256 chars + ellipsis)')