Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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):
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)')
12 changes: 11 additions & 1 deletion tests/test_validation_xml.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.xml import XmlValidator
from cyclonedx.validation.xml import XmlValidationError, XmlValidator
from tests import OWN_DATA_DIRECTORY, SCHEMA_TESTDATA_DIRECTORY, DpTuple

UNSUPPORTED_SCHEMA_VERSIONS = set()
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, 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 @@ -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)')