Skip to content
33 changes: 32 additions & 1 deletion cyclonedx/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@


from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, Literal, Optional, Protocol, Union, overload

from ..schema import OutputFormat
Expand All @@ -34,6 +35,7 @@ class ValidationError:
"""

data: Any
"""Raw error data from one of the underlying validation methods."""

def __init__(self, data: Any) -> None:
self.data = data
Expand All @@ -48,16 +50,45 @@ def __str__(self) -> str:
class SchemabasedValidator(Protocol):
"""Schema-based Validator protocol"""

def validate_str(self, data: str) -> Optional[ValidationError]:
@overload
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
"""Validate a string

:param data: the data string to validate
:param all_errors: whether to return all errors or only (any)one - if any
:return: validation error
:retval None: if ``data`` is valid
:retval ValidationError: if ``data`` is invalid
"""
... # pragma: no cover

@overload
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
"""Validate a string

:param data: the data string to validate
:param all_errors: whether to return all errors or only (any)one - if any
:return: validation error
:retval None: if ``data`` is valid
:retval Iterable[ValidationError]: if ``data`` is invalid
"""
... # pragma: no cover

def validate_str(
self, data: str, *,
all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
"""Validate a string

:param data: the data string to validate
:param all_errors: whether to return all errors or only (any)one - if any
:return: validation error
:retval None: if ``data`` is valid
:retval ValidationError: if ``data`` is invalid and ``all_errors`` is ``False``
:retval Iterable[ValidationError]: if ``data`` is invalid and ``all_errors`` is ``True``
"""
... # pragma: no cover


class BaseSchemabasedValidator(ABC, SchemabasedValidator):
"""Base Schema-based Validator"""
Expand Down
47 changes: 35 additions & 12 deletions cyclonedx/validation/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
__all__ = ['JsonValidator', 'JsonStrictValidator']

from abc import ABC
from collections.abc import Iterable
from itertools import chain
from json import loads as json_loads
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, overload

from ..schema import OutputFormat

Expand All @@ -33,7 +35,6 @@

_missing_deps_error: Optional[tuple[MissingOptionalDependencyException, ImportError]] = None
try:
from jsonschema.exceptions import ValidationError as JsonValidationError # type:ignore[import-untyped]
from jsonschema.validators import Draft7Validator # type:ignore[import-untyped]
from referencing import Registry
from referencing.jsonschema import DRAFT7
Expand All @@ -56,24 +57,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None:
# this is the def that is used for generating the documentation
super().__init__(schema_version)

# region typing-relevant copy from parent class - needed for mypy and doc tools

@overload
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
... # pragma: no cover

@overload
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
... # pragma: no cover

def validate_str(
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
... # pragma: no cover

# endregion

if _missing_deps_error: # noqa:C901
__MDERROR = _missing_deps_error

def validate_str(self, data: str) -> Optional[ValidationError]:
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
raise self.__MDERROR[0] from self.__MDERROR[1]

else:
def validate_str(self, data: str) -> Optional[ValidationError]:
return self._validata_data(
json_loads(data))

def _validata_data(self, data: Any) -> Optional[ValidationError]:
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
validator = self._validator # may throw on error that MUST NOT be caught
try:
validator.validate(data)
except JsonValidationError as error:
return ValidationError(error)
return None
structure = json_loads(data)
errors = validator.iter_errors(structure)
first_error = next(errors, None)
if first_error is None:
return None
first_error = ValidationError(first_error)
return chain((first_error,), map(ValidationError, errors)) \
if all_errors \
else first_error

__validator: Optional['JsonSchemaValidator'] = None

Expand Down
48 changes: 36 additions & 12 deletions cyclonedx/validation/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
__all__ = ['XmlValidator']

from abc import ABC
from typing import TYPE_CHECKING, Any, Literal, Optional
from collections.abc import Iterable
from typing import TYPE_CHECKING, Literal, Optional, Union, overload

from ..exception import MissingOptionalDependencyException
from ..schema import OutputFormat
Expand Down Expand Up @@ -53,23 +54,46 @@ def __init__(self, schema_version: 'SchemaVersion') -> None:
# this is the def that is used for generating the documentation
super().__init__(schema_version)

if _missing_deps_error:
# region typing-relevant copy from parent class - needed for mypy and doc tools

@overload
def validate_str(self, data: str, *, all_errors: Literal[False] = ...) -> Optional[ValidationError]:
... # pragma: no cover

@overload
def validate_str(self, data: str, *, all_errors: Literal[True]) -> Optional[Iterable[ValidationError]]:
... # pragma: no cover

def validate_str(
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
... # pragma: no cover

# endregion typing-relevant

if _missing_deps_error: # noqa:C901
__MDERROR = _missing_deps_error

def validate_str(self, data: str) -> Optional[ValidationError]:
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
raise self.__MDERROR[0] from self.__MDERROR[1]

else:
def validate_str(self, data: str) -> Optional[ValidationError]:
return self._validata_data(
xml_fromstring( # nosec B320
def validate_str( # type:ignore[no-redef] # noqa:F811 # typing-relevant headers go first
self, data: str, *, all_errors: bool = False
) -> Union[None, ValidationError, Iterable[ValidationError]]:
validator = self._validator # may throw on error that MUST NOT be caught
valid = validator.validate(
xml_fromstring( # nosec B320 -- we use a custom prepared safe parser
bytes(data, encoding='utf8'),
parser=self.__xml_parser))

def _validata_data(self, data: Any) -> Optional[ValidationError]:
validator = self._validator # may throw on error that MUST NOT be caught
if not validator.validate(data):
return ValidationError(validator.error_log.last_error)
return None
if valid:
return None
errors = validator.error_log
return map(ValidationError, errors) \
if all_errors \
else ValidationError(errors.last_error)

__validator: Optional['XMLSchema'] = None

Expand Down
42 changes: 40 additions & 2 deletions tests/test_validation_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = JsonValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
Expand All @@ -93,6 +93,25 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
self.assertIsNotNone(validation_error)
self.assertIsNotNone(validation_error.data)

@idata(chain(
_dp_sv_tf(False),
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = JsonValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
try:
validation_errors = validator.validate_str(test_data, all_errors=True)
except MissingOptionalDependencyException:
self.skipTest('MissingOptionalDependencyException')
self.assertIsNotNone(validation_errors)
validation_errors = tuple(validation_errors)
self.assertGreater(len(validation_errors), 0)
for validation_error in validation_errors:
self.assertIsNotNone(validation_error.data)


@ddt
class TestJsonStrictValidator(TestCase):
Expand Down Expand Up @@ -122,7 +141,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = JsonStrictValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
Expand All @@ -132,3 +151,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
self.skipTest('MissingOptionalDependencyException')
self.assertIsNotNone(validation_error)
self.assertIsNotNone(validation_error.data)

@idata(chain(
_dp_sv_tf(False),
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = JsonValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
try:
validation_errors = validator.validate_str(test_data, all_errors=True)
except MissingOptionalDependencyException:
self.skipTest('MissingOptionalDependencyException')
self.assertIsNotNone(validation_errors)
validation_errors = tuple(validation_errors)
self.assertGreater(len(validation_errors), 0)
for validation_error in validation_errors:
self.assertIsNotNone(validation_error.data)
21 changes: 20 additions & 1 deletion tests/test_validation_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def test_validate_no_none(self, schema_version: SchemaVersion, test_data_file: s
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_file: str) -> None:
def test_validate_expected_error_one(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = XmlValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
Expand All @@ -92,3 +92,22 @@ def test_validate_expected_error(self, schema_version: SchemaVersion, test_data_
self.skipTest('MissingOptionalDependencyException')
self.assertIsNotNone(validation_error)
self.assertIsNotNone(validation_error.data)

@idata(chain(
_dp_sv_tf(False),
_dp_sv_own(False)
))
@unpack
def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, test_data_file: str) -> None:
validator = XmlValidator(schema_version)
with open(join(test_data_file)) as tdfh:
test_data = tdfh.read()
try:
validation_errors = validator.validate_str(test_data, all_errors=True)
except MissingOptionalDependencyException:
self.skipTest('MissingOptionalDependencyException')
self.assertIsNotNone(validation_errors)
validation_errors = tuple(validation_errors)
self.assertGreater(len(validation_errors), 0)
for validation_error in validation_errors:
self.assertIsNotNone(validation_error.data)