Skip to content
20 changes: 13 additions & 7 deletions cyclonedx/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
43 changes: 41 additions & 2 deletions cyclonedx/validation/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

max(..., key=len(absolute_path)) silently picks the first child when alternative-schema errors have equal depth, so it can surface a false diagnosis from a branch that was never intended to match. On the repository's tests/_data/schemaTestData/1.6/invalid-license-declared-concluded-mix-1.6.json, validate_str(..., all_errors=True) now returns three messages saying 'license' is a required property; those entries already contain valid license or expression forms, and the real violation is mixing declared and concluded choices. The paths produced are components/0/licenses/3, components/1/licenses/0, and components/2/licenses/0.

Please make descent tie-aware (or preserve the parent oneOf/anyOf error when no child is uniquely more relevant) and add this existing fixture as a regression. jsonschema.exceptions.best_match is useful prior art here: it deliberately stops at the parent when the best nested candidates have equal relevance, rather than choosing one arbitrarily.

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):
Expand Down
19 changes: 17 additions & 2 deletions cyclonedx/validation/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,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):
Expand Down
22 changes: 21 additions & 1 deletion tests/test_validation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, }
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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)')
10 changes: 10 additions & 0 deletions tests/test_validation_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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),
Expand All @@ -132,3 +137,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)')